From ca11fe766fd4871c38681452882dbd825f6b1d4f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 20 Jun 2026 03:32:25 +0000 Subject: [PATCH 001/362] Scaffold gridfpv-server crate (v0.4 protocol server base) Empty skeleton for the embedded axum protocol server + wire types (#40 fills it). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 12 ++++++++++++ Cargo.toml | 2 ++ crates/server/Cargo.toml | 17 +++++++++++++++++ crates/server/src/lib.rs | 5 +++++ 4 files changed, 36 insertions(+) create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 98ab4a8..b4a9c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gridfpv-server" +version = "0.1.0" +dependencies = [ + "gridfpv-engine", + "gridfpv-events", + "gridfpv-projection", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "gridfpv-storage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3032d1d..f1554eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/adapters", "crates/engine", "crates/testkit", + "crates/server", "crates/app", "xtask", ] @@ -26,6 +27,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/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..b04e293 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gridfpv-server" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +gridfpv-events.workspace = true +gridfpv-projection.workspace = true +gridfpv-engine.workspace = true +serde.workspace = true +serde_json.workspace = true +ts-rs = "12" + +[dev-dependencies] diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 0000000..394e27a --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,5 @@ +//! 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. +#![forbid(unsafe_code)] From 7608fecf4f8fdb2da28a2ea6a6cabb912c56f583 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 20 Jun 2026 03:41:51 +0000 Subject: [PATCH 002/362] Add Svelte/Vite frontend monorepo scaffold (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the frontend/ npm-workspaces monorepo described in docs/clients.html §1/§2/§7: one codebase profiled per surface, sharing generated types, a thin protocol client, and a component library. Packages: - @gridfpv/types — single import seam re-exporting the ts-rs-generated repo-root bindings/ (via the @bindings/* tsconfig path alias), with a documented standalone fallback so the monorepo builds when bindings/ is absent. The frontend never hand-writes a wire type. - @gridfpv/protocol-client — STUB for #49: connect(baseUrl) signature + typed surface only. - @gridfpv/components — shared Svelte 5 component library, packaged with @sveltejs/package so apps type-check against real .d.ts. Placeholder Leaderboard + RaceClock widgets consuming @gridfpv/types. - apps/rd-console — minimal Svelte 5 + Vite shell for the RD console surface (#51+), wiring components + protocol-client + types end to end. Tooling: Svelte 5 (runes), plain Vite 6 (no SvelteKit — surfaces are client-rendered SPAs for Tauri/OBS), TypeScript strict, ESLint 9 flat config + Prettier. Root `npm run build`/`check`/`lint` fan out across workspaces. node_modules and build output added to the root .gitignore. npm install / build / check / lint all green locally. Part of #48. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .gitignore | 10 + frontend/.prettierignore | 5 + frontend/.prettierrc.json | 12 + frontend/README.md | 78 + frontend/apps/rd-console/index.html | 12 + frontend/apps/rd-console/package.json | 26 + frontend/apps/rd-console/src/App.svelte | 49 + frontend/apps/rd-console/src/main.ts | 9 + frontend/apps/rd-console/svelte.config.js | 5 + frontend/apps/rd-console/tsconfig.json | 8 + frontend/apps/rd-console/vite.config.ts | 6 + frontend/eslint.config.js | 37 + frontend/package-lock.json | 3506 +++++++++++++++++ frontend/package.json | 35 + frontend/packages/components/package.json | 38 + .../components/src/Leaderboard.svelte | 35 + .../packages/components/src/RaceClock.svelte | 24 + frontend/packages/components/src/index.ts | 10 + frontend/packages/components/svelte.config.js | 5 + frontend/packages/components/tsconfig.json | 9 + .../packages/protocol-client/package.json | 25 + .../packages/protocol-client/src/index.ts | 55 + .../packages/protocol-client/tsconfig.json | 8 + frontend/packages/types/package.json | 22 + frontend/packages/types/src/generated.ts | 41 + frontend/packages/types/src/index.ts | 12 + frontend/packages/types/tsconfig.json | 16 + frontend/tsconfig.base.json | 20 + 28 files changed, 4118 insertions(+) create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/README.md create mode 100644 frontend/apps/rd-console/index.html create mode 100644 frontend/apps/rd-console/package.json create mode 100644 frontend/apps/rd-console/src/App.svelte create mode 100644 frontend/apps/rd-console/src/main.ts create mode 100644 frontend/apps/rd-console/svelte.config.js create mode 100644 frontend/apps/rd-console/tsconfig.json create mode 100644 frontend/apps/rd-console/vite.config.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/packages/components/package.json create mode 100644 frontend/packages/components/src/Leaderboard.svelte create mode 100644 frontend/packages/components/src/RaceClock.svelte create mode 100644 frontend/packages/components/src/index.ts create mode 100644 frontend/packages/components/svelte.config.js create mode 100644 frontend/packages/components/tsconfig.json create mode 100644 frontend/packages/protocol-client/package.json create mode 100644 frontend/packages/protocol-client/src/index.ts create mode 100644 frontend/packages/protocol-client/tsconfig.json create mode 100644 frontend/packages/types/package.json create mode 100644 frontend/packages/types/src/generated.ts create mode 100644 frontend/packages/types/src/index.ts create mode 100644 frontend/packages/types/tsconfig.json create mode 100644 frontend/tsconfig.base.json diff --git a/.gitignore b/.gitignore index 3d1f56a..292318a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ # 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 diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..a102e74 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +dist +build +node_modules +.svelte-kit +packages/types/vendor diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..9bf8db1 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { "parser": "svelte" } + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..47bc13b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,78 @@ +# GridFPV frontend + +> One codebase, profiled three ways. See `docs/clients.html` §1. + +Everything that renders GridFPV is a web client of the one protocol: the RD console +(in the Tauri window), the racer/spectator PWA, and the OBS overlays. They differ only in +permissions, transport, and emphasis — not in how they talk to the Director. This monorepo is +that shared frontend: one set of generated types, one thin protocol client, one component +library, and per-surface app entry points. + +## Tooling decisions + +| Choice | Decision | Why | +| --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Framework | **Svelte 5** (runes) | Decided in `docs/clients.html` §2: compile-away runtime → tiny bundles for lean overlays/PWA, fine-grained reactivity that maps onto a realtime change-stream, low ceremony for a non-frontend maintainer. | +| Meta-framework | **No SvelteKit** — plain Vite + `@sveltejs/vite-plugin-svelte` | Surfaces are client-rendered SPAs embedded in a Tauri webview / OBS browser source. They don't need SSR, filesystem routing, or a Node server adapter; plain Vite keeps the build lean and the output static. Revisit if a surface ever needs SSR. | +| Build | **Vite 6** (apps) + **`@sveltejs/package`** (component library) | `svelte-package` emits the component library with real `.d.ts` types so apps type-check against it; Vite builds the app bundles. | +| Package manager | **npm workspaces** | No extra tooling; matches the project's lean ethos. | +| Language | **TypeScript** (strict) | First-class with Svelte 5 and with the ts-rs generated types. | +| Lint / format | **ESLint 9 (flat config) + Prettier**, with Svelte plugins | The well-supported, widely-known default — lowers the barrier to outside help. | + +## Layout + +``` +frontend/ +├── package.json # npm workspaces root; `npm run build` builds everything +├── tsconfig.base.json # shared compiler options +├── eslint.config.js # flat config, TS + Svelte +├── packages/ +│ ├── types/ # @gridfpv/types — re-exports the generated bindings/*.ts +│ ├── protocol-client/ # @gridfpv/protocol-client — thin transport+subscribe layer (STUB, filled by #49) +│ └── components/ # @gridfpv/components — shared Svelte 5 component library (svelte-package) +└── apps/ + └── rd-console/ # @gridfpv/rd-console — the RD console surface (minimal shell, filled by #51+) +``` + +Future surfaces (`apps/spectator-pwa`, `apps/overlays`) slot in beside `rd-console` as new +workspace entries; they reuse `@gridfpv/components`, `@gridfpv/protocol-client`, and +`@gridfpv/types` unchanged. + +## Generated types — the contract is generated, never transcribed + +The wire types live in the Rust server crate and are generated to TypeScript via **ts-rs** +into the repo-root `bindings/` directory (one file per type, per `docs/clients.html` §3 and +`architecture.html` §6). **The frontend never hand-writes a wire type.** + +`@gridfpv/types` is the single seam the rest of the frontend imports from. It re-exports the +generated bindings so every app and package imports protocol types from one place: + +```ts +import type { RaceSnapshot, PilotId } from '@gridfpv/types'; +``` + +How regenerated bindings flow in: + +1. The Rust side regenerates `bindings/*.ts` (e.g. `cargo test` with ts-rs, or `xtask`). +2. `packages/types/src/index.ts` re-exports from the generated barrel. While `bindings/` has + no barrel of its own, `packages/types/src/generated.ts` is the adapter that points at it + (via the `tsconfig` path alias `@bindings/*` → `../../../bindings/*`). +3. Nothing else changes: apps already import from `@gridfpv/types`, so a contract change in + Rust surfaces as a TypeScript compile error in the frontend rather than silent drift. + +**Standalone-build note.** When `bindings/` is absent (e.g. a frontend-only checkout, or CI +that hasn't run the Rust generation step), `@gridfpv/types` falls back to a small set of +placeholder types in `src/generated.ts` so the monorepo still builds and type-checks. Once +real bindings exist, that fallback is replaced by the re-export — see the comments in +`packages/types/src/generated.ts`. + +## Commands + +```bash +npm install # from frontend/ — installs all workspaces +npm run build # build components + rd-console (and any other workspace) +npm run check # svelte-check / tsc across workspaces +npm run lint # eslint + prettier --check +npm run format # prettier --write +npm run dev:rd-console # vite dev server for the RD console +``` diff --git a/frontend/apps/rd-console/index.html b/frontend/apps/rd-console/index.html new file mode 100644 index 0000000..a999bba --- /dev/null +++ b/frontend/apps/rd-console/index.html @@ -0,0 +1,12 @@ + + + + + + GridFPV — RD console + + +
+ + + diff --git a/frontend/apps/rd-console/package.json b/frontend/apps/rd-console/package.json new file mode 100644 index 0000000..ed294b8 --- /dev/null +++ b/frontend/apps/rd-console/package.json @@ -0,0 +1,26 @@ +{ + "name": "@gridfpv/rd-console", + "version": "0.0.0", + "private": true, + "description": "GridFPV RD console surface — control-oriented, dense, authenticated. Minimal shell; filled by #51+.", + "license": "AGPL-3.0-or-later", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@gridfpv/components": "*", + "@gridfpv/protocol-client": "*", + "@gridfpv/types": "*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte new file mode 100644 index 0000000..411c6fc --- /dev/null +++ b/frontend/apps/rd-console/src/App.svelte @@ -0,0 +1,49 @@ + + +
+

GridFPV — RD console

+

Scaffold shell. Connected to {client.baseUrl} (stub).

+ +
+

Race clock

+ +
+ +
+

Leaderboard

+ +
+
+ + diff --git a/frontend/apps/rd-console/src/main.ts b/frontend/apps/rd-console/src/main.ts new file mode 100644 index 0000000..dbf00e0 --- /dev/null +++ b/frontend/apps/rd-console/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte'; +import App from './App.svelte'; + +const target = document.getElementById('app'); +if (!target) { + throw new Error('rd-console: #app mount target not found'); +} + +export default mount(App, { target }); diff --git a/frontend/apps/rd-console/svelte.config.js b/frontend/apps/rd-console/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/apps/rd-console/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/apps/rd-console/tsconfig.json b/frontend/apps/rd-console/tsconfig.json new file mode 100644 index 0000000..ea0fd07 --- /dev/null +++ b/frontend/apps/rd-console/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["svelte", "vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/apps/rd-console/vite.config.ts b/frontend/apps/rd-console/vite.config.ts new file mode 100644 index 0000000..3332039 --- /dev/null +++ b/frontend/apps/rd-console/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()] +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9d4bb71 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,37 @@ +// Flat ESLint config for the GridFPV frontend monorepo. +// Covers TS + Svelte across every workspace package. +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +export default ts.config( + { + ignores: [ + '**/dist/**', + '**/build/**', + '**/node_modules/**', + '**/.svelte-kit/**', + 'packages/types/vendor/**' + ] + }, + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + prettier, + ...svelte.configs['flat/prettier'], + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + } + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ee42ba0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3506 @@ +{ + "name": "@gridfpv/frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gridfpv/frontend", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "workspaces": [ + "packages/types", + "packages/protocol-client", + "packages/components", + "apps/rd-console" + ], + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.46.1", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.2", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "svelte-eslint-parser": "^0.43.0", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.1" + } + }, + "apps/rd-console": { + "name": "@gridfpv/rd-console", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@gridfpv/components": "*", + "@gridfpv/protocol-client": "*", + "@gridfpv/types": "*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } + }, + "apps/rd-console/node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "apps/rd-console/node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "apps/rd-console/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "apps/rd-console/node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@gridfpv/components": { + "resolved": "packages/components", + "link": true + }, + "node_modules/@gridfpv/protocol-client": { + "resolved": "packages/protocol-client", + "link": true + }, + "node_modules/@gridfpv/rd-console": { + "resolved": "apps/rd-console", + "link": true + }, + "node_modules/@gridfpv/types": { + "resolved": "packages/types", + "link": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/package": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.8.tgz", + "integrity": "sha512-zeBbsXYvHiBu56v4gJaGQoEHzg96w0E1j3dOMX8vo56s6vI5eQ57ZEZhudjwjnegnVitRRu5MrmhO0eNvaonIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "kleur": "^4.1.5", + "sade": "^1.8.1", + "semver": "^7.5.4", + "svelte2tsx": "~0.7.55" + }, + "bin": { + "svelte-package": "svelte-package.js" + }, + "engines": { + "node": "^16.14 || >=18" + }, + "peerDependencies": { + "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, + "node_modules/@sveltejs/package/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sveltejs/package/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", + "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@jridgewell/sourcemap-codec": "^1.4.15", + "eslint-compat-utils": "^0.5.1", + "esutils": "^2.0.3", + "known-css-properties": "^0.35.0", + "postcss": "^8.4.38", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.1.0", + "semver": "^7.6.2", + "svelte-eslint-parser": "^0.43.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-safe-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", + "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", + "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "postcss": "^8.4.39", + "postcss-scss": "^4.0.9" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte2tsx": { + "version": "0.7.56", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.56.tgz", + "integrity": "sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, + "packages/components": { + "name": "@gridfpv/components", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@gridfpv/types": "*" + }, + "devDependencies": { + "@sveltejs/package": "^2.5.8", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.7" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "packages/components/node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "packages/components/node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "packages/components/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/components/node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "packages/protocol-client": { + "name": "@gridfpv/protocol-client", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@gridfpv/types": "*" + }, + "devDependencies": { + "typescript": "^5.7.2" + } + }, + "packages/types": { + "name": "@gridfpv/types", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "typescript": "^5.7.2" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f836c27 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "@gridfpv/frontend", + "version": "0.0.0", + "private": true, + "description": "GridFPV web frontend monorepo — one codebase, profiled three ways (RD console, spectator PWA, OBS overlays).", + "license": "AGPL-3.0-or-later", + "type": "module", + "workspaces": [ + "packages/types", + "packages/protocol-client", + "packages/components", + "apps/rd-console" + ], + "scripts": { + "build": "npm run build --workspaces --if-present", + "check": "npm run check --workspaces --if-present", + "lint": "eslint . && prettier --check .", + "format": "prettier --write .", + "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.46.1", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.2", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "svelte-eslint-parser": "^0.43.0", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.1" + } +} diff --git a/frontend/packages/components/package.json b/frontend/packages/components/package.json new file mode 100644 index 0000000..e6bc42f --- /dev/null +++ b/frontend/packages/components/package.json @@ -0,0 +1,38 @@ +{ + "name": "@gridfpv/components", + "version": "0.0.0", + "private": true, + "description": "Shared GridFPV Svelte 5 component library — race-domain widgets, themed per surface.", + "license": "AGPL-3.0-or-later", + "type": "module", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "svelte-package --input src --output dist && svelte-check --tsconfig ./tsconfig.json", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@gridfpv/types": "*" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/package": "^2.5.8", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/frontend/packages/components/src/Leaderboard.svelte b/frontend/packages/components/src/Leaderboard.svelte new file mode 100644 index 0000000..cf39cde --- /dev/null +++ b/frontend/packages/components/src/Leaderboard.svelte @@ -0,0 +1,35 @@ + + +
    + {#each snapshot.pilots as pilotId, i (pilotId)} +
  1. {i + 1}{pilotId}
  2. + {/each} +
+ + diff --git a/frontend/packages/components/src/RaceClock.svelte b/frontend/packages/components/src/RaceClock.svelte new file mode 100644 index 0000000..59232b8 --- /dev/null +++ b/frontend/packages/components/src/RaceClock.svelte @@ -0,0 +1,24 @@ + + +{label} + + diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts new file mode 100644 index 0000000..7ebb4e4 --- /dev/null +++ b/frontend/packages/components/src/index.ts @@ -0,0 +1,10 @@ +/** + * @gridfpv/components — shared GridFPV Svelte 5 component library. + * + * Race-domain widgets built once and themed per surface (RD console, spectator + * PWA, OBS overlays), per docs/clients.html §3. Placeholder set for now; later + * issues flesh out the real leaderboard, bracket tree, heat sheet, pilot card, + * standings table, etc. + */ +export { default as Leaderboard } from './Leaderboard.svelte'; +export { default as RaceClock } from './RaceClock.svelte'; diff --git a/frontend/packages/components/svelte.config.js b/frontend/packages/components/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/packages/components/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/packages/components/tsconfig.json b/frontend/packages/components/tsconfig.json new file mode 100644 index 0000000..f065488 --- /dev/null +++ b/frontend/packages/components/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["svelte"] + }, + "include": ["src"] +} diff --git a/frontend/packages/protocol-client/package.json b/frontend/packages/protocol-client/package.json new file mode 100644 index 0000000..97f2205 --- /dev/null +++ b/frontend/packages/protocol-client/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gridfpv/protocol-client", + "version": "0.0.0", + "private": true, + "description": "Thin, framework-agnostic protocol client: connect to a base URL, snapshot + WS subscribe, typed state. STUB — filled by #49.", + "license": "AGPL-3.0-or-later", + "type": "module", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@gridfpv/types": "*" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts new file mode 100644 index 0000000..b18b429 --- /dev/null +++ b/frontend/packages/protocol-client/src/index.ts @@ -0,0 +1,55 @@ +/** + * @gridfpv/protocol-client — STUB. + * + * The thin, framework-agnostic protocol layer described in docs/clients.html §3: + * connect to a base URL, fetch a projection snapshot, subscribe to the WebSocket + * change stream, and expose typed state. It is configured only with a base URL, + * so it cannot tell LAN from Cloud — the same client backs all three surfaces on + * both transports. + * + * This package currently only nails down the public surface so apps can wire + * against it. The real implementation (snapshot fetch, WS reconnect, typed + * subscriptions, auth headers) is issue #49. + */ +import type { RaceSnapshot } from '@gridfpv/types'; + +/** Options for {@link connect}. Expanded by #49 (auth token, transports, etc.). */ +export interface ConnectOptions { + /** + * Base URL of the Director (or Cloud) protocol server, e.g. + * `http://director.local:8080` or `https://cloud.gridfpv.example`. + */ + baseUrl: string; +} + +/** + * A live connection to the protocol server. The shape here is a placeholder; #49 + * defines the real snapshot/subscribe/typed-state API. + */ +export interface ProtocolClient { + readonly baseUrl: string; + /** Fetch the current projection snapshot. Implemented by #49. */ + snapshot(): Promise; + /** Close the connection and tear down any WebSocket. Implemented by #49. */ + close(): void; +} + +/** + * Connect to a GridFPV protocol server. + * + * STUB: signature only. #49 implements snapshot + WS subscribe. + * + * @param options - connection options, or a bare base URL string for convenience. + */ +export function connect(options: ConnectOptions | string): ProtocolClient { + const baseUrl = typeof options === 'string' ? options : options.baseUrl; + return { + baseUrl, + snapshot(): Promise { + return Promise.reject(new Error('protocol-client: connect() is a stub — implemented by #49')); + }, + close(): void { + /* no-op until #49 */ + } + }; +} diff --git a/frontend/packages/protocol-client/tsconfig.json b/frontend/packages/protocol-client/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/frontend/packages/protocol-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/frontend/packages/types/package.json b/frontend/packages/types/package.json new file mode 100644 index 0000000..9260ee4 --- /dev/null +++ b/frontend/packages/types/package.json @@ -0,0 +1,22 @@ +{ + "name": "@gridfpv/types", + "version": "0.0.0", + "private": true, + "description": "Single import seam for the GridFPV protocol types generated from Rust via ts-rs (repo-root bindings/).", + "license": "AGPL-3.0-or-later", + "type": "module", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts new file mode 100644 index 0000000..3719902 --- /dev/null +++ b/frontend/packages/types/src/generated.ts @@ -0,0 +1,41 @@ +/** + * Adapter to the ts-rs–generated protocol bindings. + * + * The wire types are generated from the Rust server crate into the repo-root + * `bindings/` directory (one file per type), per docs/clients.html §3 and + * architecture.html §6. The frontend NEVER hand-writes a wire type — this file + * is the only seam that knows where the generated types physically live. + * + * ── When real bindings exist ──────────────────────────────────────────────── + * `bindings/` is expected to expose a barrel (e.g. `bindings/index.ts`). Replace + * the placeholder block below with a single re-export, resolved through the + * `@bindings/*` tsconfig path alias: + * + * export * from '@bindings/index'; + * + * (or re-export the specific generated modules you need). Nothing else in the + * frontend changes, because everything imports from `@gridfpv/types`. + * + * ── Standalone fallback (bindings/ absent) ────────────────────────────────── + * Until the Rust generation step has run — e.g. a frontend-only checkout or CI + * that builds the frontend in isolation — `bindings/` may not exist. To keep the + * monorepo buildable and type-checkable on its own, we define a minimal set of + * placeholder types here. These are intentionally thin and exist only so the + * scaffold compiles; they are replaced wholesale by the generated re-export. + */ + +/** Opaque identifier for a pilot. Generated type will supersede this. */ +export type PilotId = string; + +/** Opaque identifier for a race/heat. Generated type will supersede this. */ +export type RaceId = string; + +/** + * Placeholder snapshot shape. The real, ts-rs–generated projection snapshot + * type will replace this once `bindings/` is populated. + */ +export interface RaceSnapshot { + raceId: RaceId; + /** Pilots in finishing/standings order. */ + pilots: PilotId[]; +} diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts new file mode 100644 index 0000000..e80efc8 --- /dev/null +++ b/frontend/packages/types/src/index.ts @@ -0,0 +1,12 @@ +/** + * @gridfpv/types — the single import seam for GridFPV protocol types. + * + * Every app and package imports protocol/wire types from here: + * + * import type { RaceSnapshot, PilotId } from '@gridfpv/types'; + * + * The actual definitions come from the ts-rs–generated bindings (see + * ./generated.ts for how regenerated bindings flow in, and the standalone + * fallback used when `bindings/` is absent). + */ +export * from './generated.js'; diff --git a/frontend/packages/types/tsconfig.json b/frontend/packages/types/tsconfig.json new file mode 100644 index 0000000..52d7761 --- /dev/null +++ b/frontend/packages/types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + // The generated ts-rs bindings live at the repo root. The `@bindings/*` + // alias is the one place that knows their physical location, so apps and + // packages only ever import from `@gridfpv/types`. When real bindings + // exist, src/generated.ts re-exports from here. + "@bindings/*": ["../../../bindings/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json new file mode 100644 index 0000000..44e4ae6 --- /dev/null +++ b/frontend/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + } +} From 9f005e4175e6b14d86f9b72528502b18e82a624e Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 20 Jun 2026 03:43:18 +0000 Subject: [PATCH 003/362] =?UTF-8?q?Define=20protocol=20server=20wire=20typ?= =?UTF-8?q?es=20+=20extend=20Rust=E2=86=92TS=20generation=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the scaffolded `gridfpv-server` crate with the protocol wire types — the one read/realtime contract, defined once in Rust and generated to TypeScript (protocol.html §6). No transport yet (axum is #42+): this is the type surface every later issue (#42–#45 endpoints/stream/auth/control, #49 frontend client) builds against. Wire types (crates/server/src/): - lib.rs: `ContractVersion` (§7) + `Hello`/`ServerHello` connect handshake; `CONTRACT_VERSION = 1`. - error.rs: the single shared `ProtocolError { code, message }` + `ErrorCode` (§9.8) used across HTTP reads, the WS stream, and control acks. - scope.rs: `Scope` (Event/Class/Heat/Pilot, §4) with `EventId`/`ClassId`/ `PilotId` newtypes; `SubscribeRequest { scope, from: Option }`. - stream.rs: `Cursor` (the per-stream monotonic sequence, §3/§9.5); `ChangeEnvelope { sequence, projection, change }` with `Change { Delta | FreshValue }` — the per-projection delta-vs-fresh distinction (§9.2). - snapshot.rs: `Snapshot { cursor, body }`; `ProjectionBody`/`ProjectionKind` over the existing projection/engine types + a minimal-but-real `LiveRaceState`/`HeatPhase` placeholder for #41. - control.rs: `Command` (Stage/Arm/Start/Finish/Score/Advance/Abort/Restart/ Discard, ScheduleHeat, Register, + the 5 marshaling adjudications) and `CommandAck { ok, error }` (§5). ts-rs export: add `#[derive(TS)] #[ts(export, export_to="bindings/")]` (and serde where missing) to the served projection/engine output types — `CompetitorKey`/`Lap`/`CompetitorLaps`/`LapList` (projection) and `WinCondition`/`Placement`/`Metric`/`HeatResult`/`RankEntry`/`CompletedHeat`/ `EventOutcome` (engine) — so the snapshot/envelope embed generated types. Add `ts-rs` (+ `serde` for engine) to those crates' manifests. xtask: `gen_bindings` now runs `cargo test --workspace export_bindings` so events, projection, engine, and server all export, `TS_RS_EXPORT_DIR` still pinned to the repo root. Drift check passes with the new bindings/*.ts. Deferred (noted in-code): exact per-projection delta encodings (#43, Change:: Delta is a serde_json::Value placeholder), the full scope addressing grammar (#42/§9.6), LiveRaceState detail (#41), and auth token format (#44). Unit tests: serde round-trips for snapshot, change envelope, command, error, and the handshake types. `cargo xtask ci` green (fmt, clippy -D warnings, tests, gen drift check). Part of #40. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 3 + bindings/Change.ts | 19 ++ bindings/ChangeEnvelope.ts | 29 ++++ bindings/ClassId.ts | 7 + bindings/Command.ts | 128 ++++++++++++++ bindings/CommandAck.ts | 23 +++ bindings/CompetitorKey.ts | 22 +++ bindings/CompetitorLaps.ts | 16 ++ bindings/CompletedHeat.ts | 21 +++ bindings/ContractVersion.ts | 14 ++ bindings/Cursor.ts | 17 ++ bindings/ErrorCode.ts | 12 ++ bindings/EventId.ts | 10 ++ bindings/EventOutcome.ts | 30 ++++ bindings/HeatPhase.ts | 13 ++ bindings/HeatResult.ts | 15 ++ bindings/Hello.ts | 18 ++ bindings/Lap.ts | 15 ++ bindings/LapList.ts | 14 ++ bindings/LiveRaceState.ts | 24 +++ bindings/Metric.ts | 8 + bindings/PilotId.ts | 12 ++ bindings/Placement.ts | 31 ++++ bindings/ProjectionBody.ts | 18 ++ bindings/ProjectionKind.ts | 12 ++ bindings/ProtocolError.ts | 19 ++ bindings/RankEntry.ts | 21 +++ bindings/Scope.ts | 41 +++++ bindings/ServerHello.ts | 26 +++ bindings/Snapshot.ts | 23 +++ bindings/SubscribeRequest.ts | 24 +++ bindings/WinCondition.ts | 19 ++ crates/engine/Cargo.toml | 4 + crates/engine/src/event.rs | 5 +- crates/engine/src/format.rs | 8 +- crates/engine/src/scoring.rs | 14 +- crates/projection/Cargo.toml | 3 + crates/projection/src/lib.rs | 13 +- crates/server/src/control.rs | 316 ++++++++++++++++++++++++++++++++++ crates/server/src/error.rs | 102 +++++++++++ crates/server/src/lib.rs | 154 +++++++++++++++++ crates/server/src/scope.rs | 164 ++++++++++++++++++ crates/server/src/snapshot.rs | 215 +++++++++++++++++++++++ crates/server/src/stream.rs | 142 +++++++++++++++ xtask/src/main.rs | 21 ++- 45 files changed, 1843 insertions(+), 22 deletions(-) create mode 100644 bindings/Change.ts create mode 100644 bindings/ChangeEnvelope.ts create mode 100644 bindings/ClassId.ts create mode 100644 bindings/Command.ts create mode 100644 bindings/CommandAck.ts create mode 100644 bindings/CompetitorKey.ts create mode 100644 bindings/CompetitorLaps.ts create mode 100644 bindings/CompletedHeat.ts create mode 100644 bindings/ContractVersion.ts create mode 100644 bindings/Cursor.ts create mode 100644 bindings/ErrorCode.ts create mode 100644 bindings/EventId.ts create mode 100644 bindings/EventOutcome.ts create mode 100644 bindings/HeatPhase.ts create mode 100644 bindings/HeatResult.ts create mode 100644 bindings/Hello.ts create mode 100644 bindings/Lap.ts create mode 100644 bindings/LapList.ts create mode 100644 bindings/LiveRaceState.ts create mode 100644 bindings/Metric.ts create mode 100644 bindings/PilotId.ts create mode 100644 bindings/Placement.ts create mode 100644 bindings/ProjectionBody.ts create mode 100644 bindings/ProjectionKind.ts create mode 100644 bindings/ProtocolError.ts create mode 100644 bindings/RankEntry.ts create mode 100644 bindings/Scope.ts create mode 100644 bindings/ServerHello.ts create mode 100644 bindings/Snapshot.ts create mode 100644 bindings/SubscribeRequest.ts create mode 100644 bindings/WinCondition.ts create mode 100644 crates/server/src/control.rs create mode 100644 crates/server/src/error.rs create mode 100644 crates/server/src/scope.rs create mode 100644 crates/server/src/snapshot.rs create mode 100644 crates/server/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index b4a9c70..2731ebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,9 @@ dependencies = [ "gridfpv-events", "gridfpv-projection", "gridfpv-testkit", + "serde", "serde_json", + "ts-rs", ] [[package]] @@ -432,6 +434,7 @@ dependencies = [ "gridfpv-events", "serde", "serde_json", + "ts-rs", ] [[package]] diff --git a/bindings/Change.ts b/bindings/Change.ts new file mode 100644 index 0000000..2ae6a93 --- /dev/null +++ b/bindings/Change.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProjectionBody } from "./ProjectionBody"; + +/** + * How an envelope conveys a projection change (protocol.html §3, §9.2): a **delta** + * for append-heavy projections (lap lists, live state) or a **fresh value** for cheap + * or re-folded ones (a heat result, a ranking after a marshaling correction). + * + * Externally tagged, mapping to a TS discriminated union. + * + * > **Deferred (#43):** the exact per-projection delta *encodings* are a placeholder. + * > [`Change::Delta`] carries a `serde_json::Value` rather than a typed delta for each + * > [`ProjectionKind`]; the precise delta shapes (a single appended lap, a heat-state + * > transition) are pinned when the change stream lands. The *structure* — the + * > per-projection delta-vs-fresh-value distinction protocol.html §9.2 fixes — is + * > what matters now and is captured here. A fresh value, by contrast, is already a + * > fully-typed [`ProjectionBody`]. + */ +export type Change = { "Delta": unknown } | { "FreshValue": ProjectionBody }; diff --git a/bindings/ChangeEnvelope.ts b/bindings/ChangeEnvelope.ts new file mode 100644 index 0000000..83233a8 --- /dev/null +++ b/bindings/ChangeEnvelope.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Change } from "./Change"; +import type { Cursor } from "./Cursor"; +import type { ProjectionKind } from "./ProjectionKind"; + +/** + * A change envelope (protocol.html §3): one ordered, sequenced update to a single + * projection. + * + * Each envelope carries its monotonic `sequence` [`Cursor`], names the `projection` it + * touches (as a [`ProjectionKind`] — for a [`Change::FreshValue`] the body also + * carries the kind, but naming it here keeps deltas and fresh values uniform), and a + * `change` that is a delta or a fresh value. The client applies envelopes in strictly + * increasing `sequence` order; re-delivering one already applied is a no-op keyed by + * sequence (§3 "idempotent application", "at-least-once, deduplicated"). + */ +export type ChangeEnvelope = { +/** + * This envelope's position in the per-stream sequence. + */ +sequence: Cursor, +/** + * Which projection this change advances. + */ +projection: ProjectionKind, +/** + * The delta or fresh value to apply. + */ +change: Change, }; diff --git a/bindings/ClassId.ts b/bindings/ClassId.ts new file mode 100644 index 0000000..d4a4685 --- /dev/null +++ b/bindings/ClassId.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Identifies a **class** within an event (protocol.html §4 "Class scope") — one + * class's phases, schedule, and standings, which may run in parallel with others. + */ +export type ClassId = string; diff --git a/bindings/Command.ts b/bindings/Command.ts new file mode 100644 index 0000000..1fc4df9 --- /dev/null +++ b/bindings/Command.ts @@ -0,0 +1,128 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdapterId } from "./AdapterId"; +import type { CompetitorRef } from "./CompetitorRef"; +import type { HeatId } from "./HeatId"; +import type { LogRef } from "./LogRef"; +import type { Penalty } from "./Penalty"; +import type { PilotId } from "./PilotId"; +import type { SourceTime } from "./SourceTime"; + +/** + * A privileged RD control command (protocol.html §5). Externally tagged like the + * event model, so it maps to a TS discriminated union. + * + * The variants fall into four groups: + * + * - **Heat-loop transitions** — [`Stage`](Command::Stage), [`Arm`](Command::Arm), + * [`Start`](Command::Start), [`Finish`](Command::Finish), [`Score`](Command::Score), + * [`Advance`](Command::Advance), and the off-ramps [`Abort`](Command::Abort), + * [`Restart`](Command::Restart), [`Discard`](Command::Discard). Each requests the + * matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine validates + * it against the heat's current state (race-engine.html §2). + * - **Scheduling** — [`ScheduleHeat`](Command::ScheduleHeat) creates a heat with its + * lineup ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)). + * - **Registration** — [`Register`](Command::Register) binds a source-local + * competitor to a pilot (the binding the adapter never does itself; Architecture §9). + * - **Marshaling adjudications** — the five corrections + * ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap), + * [`AdjustLap`](Command::AdjustLap), [`VoidHeat`](Command::VoidHeat), + * [`ApplyPenalty`](Command::ApplyPenalty)), each requesting the corresponding + * marshaling event the projection folds in (never a mutation; architecture.html §3). + */ +export type Command = { "Stage": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Arm": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Start": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Finish": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Score": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Advance": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Abort": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Restart": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Discard": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "ScheduleHeat": { +/** + * The id the new heat will carry. + */ +heat: HeatId, +/** + * The competitors in the heat, in lineup order. + */ +lineup: Array, } } | { "Register": { +/** + * The timing source the competitor belongs to. + */ +adapter: AdapterId, +/** + * The source-local competitor handle being bound. + */ +competitor: CompetitorRef, +/** + * The event-scoped pilot the competitor is bound to. + */ +pilot: PilotId, } } | { "VoidDetection": { +/** + * The log offset of the pass (or ruling) to void. + */ +target: LogRef, } } | { "InsertLap": { +/** + * The timing source to attribute the inserted pass to. + */ +adapter: AdapterId, +/** + * The competitor the inserted lap belongs to. + */ +competitor: CompetitorRef, +/** + * When the inserted crossing happened, on the source clock. + */ +at: SourceTime, } } | { "AdjustLap": { +/** + * The log offset of the pass to re-time. + */ +target: LogRef, +/** + * The corrected crossing time, on the source clock. + */ +at: SourceTime, } } | { "VoidHeat": { +/** + * The heat to void. + */ +heat: HeatId, } } | { "ApplyPenalty": { +/** + * The heat the penalty applies in. + */ +heat: HeatId, +/** + * The competitor penalized. + */ +competitor: CompetitorRef, +/** + * The penalty applied. + */ +penalty: Penalty, } }; diff --git a/bindings/CommandAck.ts b/bindings/CommandAck.ts new file mode 100644 index 0000000..a1c8b02 --- /dev/null +++ b/bindings/CommandAck.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProtocolError } from "./ProtocolError"; + +/** + * The acknowledgement of a [`Command`] (protocol.html §5): commands up, + * acknowledgements down. + * + * `ok` is the success flag; on failure `error` carries the single shared + * [`ProtocolError`](crate::error::ProtocolError) (§9.8) — an illegal transition for + * the heat's state, an unauthorized caller, an unknown heat. On success `error` is + * `None`. (The resulting projection state flows back separately as + * [`ChangeEnvelope`](crate::stream::ChangeEnvelope)s on the read stream, not in the + * ack.) + */ +export type CommandAck = { +/** + * Whether the command was accepted and applied. + */ +ok: boolean, +/** + * The failure detail when `ok` is `false`; `None` on success. + */ +error?: ProtocolError, }; diff --git a/bindings/CompetitorKey.ts b/bindings/CompetitorKey.ts new file mode 100644 index 0000000..6fb8671 --- /dev/null +++ b/bindings/CompetitorKey.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdapterId } from "./AdapterId"; +import type { CompetitorRef } from "./CompetitorRef"; + +/** + * Identifies a competitor *within a single timing source*. + * + * A source-local [`CompetitorRef`] is only meaningful relative to the adapter + * that emitted it (node 2 on RotorHazard is unrelated to node 2 on a second + * timer), so laps are grouped on the `(AdapterId, CompetitorRef)` pair. Binding + * these per-source competitors to a single GridFPV pilot is a later registration + * concern (Architecture §9) and deliberately out of scope here. + */ +export type CompetitorKey = { +/** + * The timing source the competitor belongs to. + */ +adapter: AdapterId, +/** + * The source-local competitor handle. + */ +competitor: CompetitorRef, }; diff --git a/bindings/CompetitorLaps.ts b/bindings/CompetitorLaps.ts new file mode 100644 index 0000000..8717f70 --- /dev/null +++ b/bindings/CompetitorLaps.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorKey } from "./CompetitorKey"; +import type { Lap } from "./Lap"; + +/** + * Every lap a single competitor completed, in order. + */ +export type CompetitorLaps = { +/** + * Which source-local competitor these laps belong to. + */ +competitor: CompetitorKey, +/** + * Completed laps, ordered by lap number (1-based, ascending). + */ +laps: Array, }; diff --git a/bindings/CompletedHeat.ts b/bindings/CompletedHeat.ts new file mode 100644 index 0000000..78a1b67 --- /dev/null +++ b/bindings/CompletedHeat.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HeatId } from "./HeatId"; +import type { HeatResult } from "./HeatResult"; + +/** + * A scored heat fed back into the generator: the heat's id and its [`HeatResult`]. + * + * This is the generator's *only* input about what happened — it consumes finished, + * scored heats (produced by [`crate::scoring::score`]) and never raw passes. The + * `heat` id ties the result back to the [`HeatPlan`] that produced it, so a + * generator that emitted several heats can tell which result is which. + */ +export type CompletedHeat = { +/** + * Which planned heat this result is for. + */ +heat: HeatId, +/** + * The scored result of that heat. + */ +result: HeatResult, }; diff --git a/bindings/ContractVersion.ts b/bindings/ContractVersion.ts new file mode 100644 index 0000000..5adfafd --- /dev/null +++ b/bindings/ContractVersion.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The protocol **contract version** — axis 1 of the three version axes + * (protocol.html §7): the wire shapes owned by this crate, kept distinct from the + * log/schema version and the projection version. + * + * A single monotonic integer. A breaking change to any wire type bumps it; additive + * changes (new projections, new fields an older client ignores) do not. The version + * is negotiated at connect time ([`Hello`] / [`ServerHello`]) and is independent of + * the transport (LAN or Cloud) — see [`CONTRACT_VERSION`] for the version this build + * speaks. + */ +export type ContractVersion = number; diff --git a/bindings/Cursor.ts b/bindings/Cursor.ts new file mode 100644 index 0000000..b193c80 --- /dev/null +++ b/bindings/Cursor.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The public **projection sequence number** (protocol.html §3, §9.5): a single + * monotonic integer per stream, increasing by one per envelope. + * + * It is *not* the raw log offset — one log append can fan out into several projection + * changes or none a client subscribes to. The protocol commits to this projection + * sequence as its public ordering; the log offset stays a private Director/Cloud + * detail. The snapshot hands the client a starting cursor (§2) and every envelope + * advances it; on reconnect the client presents its last-applied cursor to resume + * (§3). + * + * Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint` + * (a `u64` exceeds JS's safe-integer range), matching how it serialises. + */ +export type Cursor = bigint; diff --git a/bindings/ErrorCode.ts b/bindings/ErrorCode.ts new file mode 100644 index 0000000..6b50bf9 --- /dev/null +++ b/bindings/ErrorCode.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A machine-readable error category — the cases protocol.html §9.8 enumerates + * ("auth failure, unknown scope, stale-cursor, version-mismatch"), plus a generic + * fallback. Externally tagged like the event model so it maps to a TS string union. + * + * Adding a new variant is an additive change (§7): an older client that does not + * understand a new code treats it as it would [`ErrorCode::Internal`] — an error it + * surfaces but cannot specifically branch on. + */ +export type ErrorCode = "Unauthorized" | "UnknownScope" | "StaleCursor" | "VersionMismatch" | "BadRequest" | "Internal"; diff --git a/bindings/EventId.ts b/bindings/EventId.ts new file mode 100644 index 0000000..9f13d2e --- /dev/null +++ b/bindings/EventId.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Identifies an **event** — the whole competition (protocol.html §4 "Event scope"). + * + * A transparent string newtype like the event-model ids. The cross-event registry + * and account model are a Cloud concern (protocol.html callout); this is just the + * stable handle a scope addresses, sufficient for the wire contract. + */ +export type EventId = string; diff --git a/bindings/EventOutcome.ts b/bindings/EventOutcome.ts new file mode 100644 index 0000000..2e4241f --- /dev/null +++ b/bindings/EventOutcome.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorRef } from "./CompetitorRef"; +import type { CompletedHeat } from "./CompletedHeat"; +import type { RankEntry } from "./RankEntry"; + +/** + * The result of running a whole event: the qualifying ranking that seeded the bracket, + * the bracket's final standings, and the single winner at the top of them. + */ +export type EventOutcome = { +/** + * The qualifying phase's final ranking (best seed first) — what seeds the bracket. + */ +qualifying: Array, +/** + * The completed qualifying heats, in run order. + */ +qualifying_heats: Array, +/** + * The seeded field that entered the bracket: the top `bracket_size` of `qualifying`. + */ +bracket_seeds: Array, +/** + * The bracket's final ranking (winner first) — the event standings. + */ +bracket: Array, +/** + * The completed bracket heats, in run order. + */ +bracket_heats: Array, }; diff --git a/bindings/HeatPhase.ts b/bindings/HeatPhase.ts new file mode 100644 index 0000000..d0abac6 --- /dev/null +++ b/bindings/HeatPhase.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged → + * Armed → Running → Landed → Scored`). + * + * This is the *projected* view of the heat loop — the folded current phase a client + * renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the + * engine appends. The off-ramp transitions (abort/restart/discard) resolve back onto + * one of these phases, so the live view stays a simple linear status. A #41-era detail + * the placeholder pins minimally. + */ +export type HeatPhase = "Scheduled" | "Staged" | "Armed" | "Running" | "Landed" | "Scored"; diff --git a/bindings/HeatResult.ts b/bindings/HeatResult.ts new file mode 100644 index 0000000..1811675 --- /dev/null +++ b/bindings/HeatResult.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Placement } from "./Placement"; + +/** + * The scored heat: every competitor's [`Placement`], best position first. + * + * Ties share a `position` (see [`Placement::position`]). The order within a tie + * group is still deterministic — competitors are ordered by [`CompetitorKey`] as + * the final, total tie-break — but their `position` numbers are equal. + */ +export type HeatResult = { +/** + * Placements in finishing order (ties adjacent, sharing a position). + */ +places: Array, }; diff --git a/bindings/Hello.ts b/bindings/Hello.ts new file mode 100644 index 0000000..91e1013 --- /dev/null +++ b/bindings/Hello.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContractVersion } from "./ContractVersion"; + +/** + * The client's **connect message** (protocol.html §7): announced before anything + * else, it carries the [`ContractVersion`] the client was built against so the + * server can decide whether it can serve it. + * + * "The contract version is negotiated, not assumed" (§7): the client states its + * version, the server replies with the band it serves ([`ServerHello`]). The version + * is the only thing negotiated here; auth (a bearer token, §9.4) is a separate #44 + * concern layered in front, not part of this message yet. + */ +export type Hello = { +/** + * The contract version the client was built against. + */ +contract_version: ContractVersion, }; diff --git a/bindings/Lap.ts b/bindings/Lap.ts new file mode 100644 index 0000000..bb48203 --- /dev/null +++ b/bindings/Lap.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single completed lap: the interval between two consecutive lap-gate passes. + */ +export type Lap = { +/** + * 1-based lap number within the competitor's run. + */ +number: number, +/** + * Lap duration in microseconds on the source clock + * (`pass[n + 1].at - pass[n].at`). Always `>= 0` for in-order passes. + */ +duration_micros: bigint, }; diff --git a/bindings/LapList.ts b/bindings/LapList.ts new file mode 100644 index 0000000..bb16e08 --- /dev/null +++ b/bindings/LapList.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorLaps } from "./CompetitorLaps"; + +/** + * The lap-list read model: per-competitor lap lists derived from the log. + * + * Competitors are ordered deterministically by [`CompetitorKey`] so the + * projection is stable across runs regardless of event arrival order. + */ +export type LapList = { +/** + * Per-competitor laps, ordered by competitor key. + */ +competitors: Array, }; diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts new file mode 100644 index 0000000..57d8b82 --- /dev/null +++ b/bindings/LiveRaceState.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HeatId } from "./HeatId"; +import type { HeatPhase } from "./HeatPhase"; + +/** + * The current **live race-state** projection (protocol.html §1) — the latency-sensitive + * core every overlay and spectator watches: the current heat and its loop state, the + * active pilots' live lap/split progress, the running order, and the on-deck heat. + * + * **#41 placeholder.** This is intentionally minimal-but-real: it carries the current + * heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the + * change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot + * live progress, the running order, and the on-deck heat. Keeping the type real (not a + * unit stub) means #41 extends fields additively (§7) without reshaping the envelope. + */ +export type LiveRaceState = { +/** + * The heat currently on the timer, if any (`None` between heats). + */ +current_heat?: HeatId, +/** + * The current heat's loop phase (protocol.html §1, race-engine.html §2). + */ +phase: HeatPhase, }; diff --git a/bindings/Metric.ts b/bindings/Metric.ts new file mode 100644 index 0000000..93c2e69 --- /dev/null +++ b/bindings/Metric.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SourceTime } from "./SourceTime"; + +/** + * The condition-specific value a [`Placement`] was ranked on, kept for display and + * for tests to assert against exact numbers. + */ +export type Metric = { "LastLapAt": SourceTime | null } | { "ReachedAt": SourceTime | null } | { "BestLapMicros": bigint | null } | { "BestConsecutiveMicros": bigint | null }; diff --git a/bindings/PilotId.ts b/bindings/PilotId.ts new file mode 100644 index 0000000..63cfca9 --- /dev/null +++ b/bindings/PilotId.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer + * following their own laps, results, and next heat. + * + * This is the event-scoped pilot handle a scope addresses, distinct from the + * per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a + * pilot is bound to source competitors by a registration action (Architecture §9), + * which is out of scope here. + */ +export type PilotId = string; diff --git a/bindings/Placement.ts b/bindings/Placement.ts new file mode 100644 index 0000000..32cf523 --- /dev/null +++ b/bindings/Placement.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorKey } from "./CompetitorKey"; +import type { Metric } from "./Metric"; + +/** + * One competitor's place in a scored heat. + * + * `position` is **1-based and dense at the top but tie-aware**: tied competitors + * share the same `position`, and the next distinct competitor's `position` skips + * past them (standard "1, 2, 2, 4" competition ranking). `laps` is the number of + * laps that counted under the win condition (for [`WinCondition::Timed`] that is + * the number inside the window, not the raw laps flown). `metric` carries the + * condition-specific deciding value for display / debugging. + */ +export type Placement = { +/** + * Which source-local competitor this placement is for. + */ +competitor: CompetitorKey, +/** + * 1-based finishing position; tied competitors share a position. + */ +position: number, +/** + * Laps that counted under the win condition. + */ +laps: number, +/** + * The condition-specific deciding metric for this competitor. + */ +metric: Metric, }; diff --git a/bindings/ProjectionBody.ts b/bindings/ProjectionBody.ts new file mode 100644 index 0000000..04b980e --- /dev/null +++ b/bindings/ProjectionBody.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EventOutcome } from "./EventOutcome"; +import type { HeatResult } from "./HeatResult"; +import type { LapList } from "./LapList"; +import type { LiveRaceState } from "./LiveRaceState"; +import type { RankEntry } from "./RankEntry"; + +/** + * A served projection **with its value** (protocol.html §1) — the closed set of + * projection shapes the contract carries, each reusing the existing projection/engine + * output type. Externally tagged, so it maps to a TS discriminated union keyed by + * projection kind. + * + * This is the body of a [`Snapshot`] and of a fresh-value + * [`ChangeEnvelope`](crate::stream::ChangeEnvelope). Adding a new served projection is + * an additive variant (§7); an older client ignores a kind it does not understand. + */ +export type ProjectionBody = { "LiveRaceState": LiveRaceState } | { "LapList": LapList } | { "HeatResult": HeatResult } | { "Ranking": Array } | { "EventOutcome": EventOutcome }; diff --git a/bindings/ProjectionKind.ts b/bindings/ProjectionKind.ts new file mode 100644 index 0000000..5ad99c3 --- /dev/null +++ b/bindings/ProjectionKind.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Which projection a snapshot body or change envelope is *about* — the bare + * discriminant with no payload (protocol.html §3). + * + * A fresh-value envelope carries the whole [`ProjectionBody`] (kind *and* value); a + * delta envelope carries this `ProjectionKind` plus an opaque delta, naming the + * projection it advances without re-sending it. Keeping the kind separate from the + * body lets a delta name its target cheaply. + */ +export type ProjectionKind = "LiveRaceState" | "LapList" | "HeatResult" | "Ranking" | "EventOutcome"; diff --git a/bindings/ProtocolError.ts b/bindings/ProtocolError.ts new file mode 100644 index 0000000..242d526 --- /dev/null +++ b/bindings/ProtocolError.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ErrorCode } from "./ErrorCode"; + +/** + * The one error shape every protocol surface returns (protocol.html §9.8). + * + * Carried in an HTTP error body, a WS error frame, and a failed + * [`CommandAck`](crate::control::CommandAck) alike. `code` is the branchable + * category; `message` is the human-readable detail for logs and the RD console. + */ +export type ProtocolError = { +/** + * The machine-readable error category. + */ +code: ErrorCode, +/** + * A human-readable description of what went wrong. + */ +message: string, }; diff --git a/bindings/RankEntry.ts b/bindings/RankEntry.ts new file mode 100644 index 0000000..c0e28a0 --- /dev/null +++ b/bindings/RankEntry.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorRef } from "./CompetitorRef"; + +/** + * One competitor's place in a generator's overall ranking. + * + * `position` is **1-based and tie-aware** with the same "competition ranking" + * convention as [`crate::scoring::Placement`]: tied competitors share a `position` + * and the next distinct entry skips past them (1, 2, 2, 4). Entries are returned in + * ranking order (best first), with a total, deterministic tie-break so the order is + * stable across runs. + */ +export type RankEntry = { +/** + * The competitor this entry ranks. + */ +competitor: CompetitorRef, +/** + * 1-based overall position; tied competitors share a position. + */ +position: number, }; diff --git a/bindings/Scope.ts b/bindings/Scope.ts new file mode 100644 index 0000000..ab08c8b --- /dev/null +++ b/bindings/Scope.ts @@ -0,0 +1,41 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClassId } from "./ClassId"; +import type { EventId } from "./EventId"; +import type { HeatId } from "./HeatId"; +import type { PilotId } from "./PilotId"; + +/** + * What a subscription addresses (protocol.html §4): one of the four resources a + * client can scope to. Externally tagged like the event model, so it maps to a TS + * discriminated union. + * + * Scopes compose and a client may hold several at once over one connection (§4); a + * multi-scope subscribe is a list of these. The richer filter grammar (§9.6) is + * deferred — this fixes the addressable *resources*, which is what later issues build + * the grammar over. + */ +export type Scope = { "Event": { +/** + * The event addressed. + */ +event: EventId, } } | { "Class": { +/** + * The event the class belongs to. + */ +event: EventId, +/** + * The class addressed. + */ +class: ClassId, } } | { "Heat": { +/** + * The heat addressed (the id it carries in the log). + */ +heat: HeatId, } } | { "Pilot": { +/** + * The event the pilot is racing in. + */ +event: EventId, +/** + * The pilot addressed. + */ +pilot: PilotId, } }; diff --git a/bindings/ServerHello.ts b/bindings/ServerHello.ts new file mode 100644 index 0000000..8a94cc3 --- /dev/null +++ b/bindings/ServerHello.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContractVersion } from "./ContractVersion"; + +/** + * The server's reply to a [`Hello`] (protocol.html §7). + * + * The server "states the version(s) it serves" as an inclusive band + * `[min_contract_version, max_contract_version]`. A client whose + * [`Hello::contract_version`] falls inside the band is served; one that falls + * outside gets `compatible = false` — the explicit "too old / too new, please + * refresh" signal — rather than malformed data. + */ +export type ServerHello = { +/** + * The oldest contract version this server still serves (inclusive). + */ +min_contract_version: ContractVersion, +/** + * The newest contract version this server serves (inclusive). + */ +max_contract_version: ContractVersion, +/** + * Whether the client's announced version falls within the served band. `false` + * is the "please refresh" signal (the client is too old or too new to be served). + */ +compatible: boolean, }; diff --git a/bindings/Snapshot.ts b/bindings/Snapshot.ts new file mode 100644 index 0000000..d9d81c0 --- /dev/null +++ b/bindings/Snapshot.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Cursor } from "./Cursor"; +import type { ProjectionBody } from "./ProjectionBody"; + +/** + * A snapshot of a scoped projection (protocol.html §2): the current materialized + * [`ProjectionBody`] plus the [`Cursor`] the change stream resumes from. + * + * "Snapshot first, then subscribe" (§2): the client renders the `body` immediately and + * opens the stream `from` this `cursor`, so the first envelope it applies is exactly + * the one after the snapshot — nothing missed, nothing double-applied. Re-snapshotting + * is always correct because projections are recomputable; the stream is an + * optimization, never the source of truth (§3). + */ +export type Snapshot = { +/** + * The stream cursor this snapshot was taken at — where the subscription resumes. + */ +cursor: Cursor, +/** + * The materialized projection at that cursor. + */ +body: ProjectionBody, }; diff --git a/bindings/SubscribeRequest.ts b/bindings/SubscribeRequest.ts new file mode 100644 index 0000000..33512c6 --- /dev/null +++ b/bindings/SubscribeRequest.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Cursor } from "./Cursor"; +import type { Scope } from "./Scope"; + +/** + * A subscription request (protocol.html §3, §4): a [`Scope`] plus where to resume + * the stream from. + * + * `from` is the last-applied [`Cursor`](crate::stream::Cursor) the client presents on + * (re)connect — `Some(cursor)` to resume after a drop, `None` for a fresh + * subscription that begins from the snapshot's cursor (protocol.html §3 "resume by + * cursor, fall back to re-snapshot"). If the gap is too old to replay the server + * answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the + * client re-snapshots. + */ +export type SubscribeRequest = { +/** + * The resource this subscription is scoped to. + */ +scope: Scope, +/** + * The cursor to resume after, or `None` to start fresh from the snapshot. + */ +from?: Cursor, }; diff --git a/bindings/WinCondition.ts b/bindings/WinCondition.ts new file mode 100644 index 0000000..872c2ab --- /dev/null +++ b/bindings/WinCondition.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How a heat is won — the configured per-heat / per-format scoring rule + * (race-engine.html §4, §7.1). + */ +export type WinCondition = { "Timed": { +/** + * Window length in microseconds, measured from the race start. + */ +window_micros: bigint, } } | { "FirstToLaps": { +/** + * The target lap count. + */ +n: number, } } | "BestLap" | { "BestConsecutive": { +/** + * How many consecutive laps the window spans. + */ +n: number, } }; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1b53548..0c7ab8a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,6 +19,10 @@ gridfpv-events.workspace = true # Scoring (#30) and the other engine features build on the projection; add it now so # the v0.3 wave that lands them doesn't have to touch this manifest again. gridfpv-projection.workspace = true +serde.workspace = true +# Rust→TypeScript type export (#4, #40): heat results, rankings, and the full-event +# outcome are served snapshot bodies, so their public output types generate TS bindings. +ts-rs = "12" gridfpv-adapters = { workspace = true, optional = true } gridfpv-testkit = { workspace = true, optional = true } diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index cf72204..0139f64 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -44,6 +44,8 @@ use std::collections::BTreeMap; use gridfpv_events::{Event, Pass, SourceTime}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry, advance_top_n}; use crate::scoring::{HeatResult, WinCondition, score}; @@ -92,7 +94,8 @@ pub fn run_format( /// The result of running a whole event: the qualifying ranking that seeded the bracket, /// the bracket's final standings, and the single winner at the top of them. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct EventOutcome { /// The qualifying phase's final ranking (best seed first) — what seeds the bracket. pub qualifying: Vec, diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index 5c4995d..e76436c 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -48,6 +48,8 @@ use std::collections::BTreeMap; use gridfpv_events::{CompetitorRef, HeatId}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::scoring::HeatResult; @@ -80,7 +82,8 @@ impl HeatPlan { /// scored heats (produced by [`crate::scoring::score`]) and never raw passes. The /// `heat` id ties the result back to the [`HeatPlan`] that produced it, so a /// generator that emitted several heats can tell which result is which. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct CompletedHeat { /// Which planned heat this result is for. pub heat: HeatId, @@ -119,7 +122,8 @@ pub enum GeneratorStep { /// and the next distinct entry skips past them (1, 2, 2, 4). Entries are returned in /// ranking order (best first), with a total, deterministic tie-break so the order is /// stable across runs. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct RankEntry { /// The competitor this entry ranks. pub competitor: CompetitorRef, diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs index 503446d..cd5cf64 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -41,10 +41,13 @@ use gridfpv_events::{Event, Pass, SourceTime}; use gridfpv_projection::CompetitorKey; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// How a heat is won — the configured per-heat / per-format scoring rule /// (race-engine.html §4, §7.1). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub enum WinCondition { /// **Most laps in a time window.** A shared race clock runs from `race_start` /// for `window_micros`. A lap counts only if its completing pass falls @@ -86,7 +89,8 @@ pub enum WinCondition { /// laps that counted under the win condition (for [`WinCondition::Timed`] that is /// the number inside the window, not the raw laps flown). `metric` carries the /// condition-specific deciding value for display / debugging. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct Placement { /// Which source-local competitor this placement is for. pub competitor: CompetitorKey, @@ -100,7 +104,8 @@ pub struct Placement { /// The condition-specific value a [`Placement`] was ranked on, kept for display and /// for tests to assert against exact numbers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub enum Metric { /// [`WinCondition::Timed`]: completion time (µs, source clock) of the last /// counted lap, or `None` if no lap counted. @@ -120,7 +125,8 @@ pub enum Metric { /// Ties share a `position` (see [`Placement::position`]). The order within a tie /// group is still deterministic — competitors are ordered by [`CompetitorKey`] as /// the final, total tie-break — but their `position` numbers are equal. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct HeatResult { /// Placements in finishing order (ties adjacent, sharing a position). pub places: Vec, diff --git a/crates/projection/Cargo.toml b/crates/projection/Cargo.toml index 23b6dcb..d661537 100644 --- a/crates/projection/Cargo.toml +++ b/crates/projection/Cargo.toml @@ -9,6 +9,9 @@ rust-version.workspace = true [dependencies] gridfpv-events.workspace = true serde.workspace = true +# Rust→TypeScript type export (#4, #40): the lap projection is a served snapshot +# body, so its public output types generate TS bindings alongside the event model. +ts-rs = "12" [dev-dependencies] serde_json.workspace = true diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 2593f59..6812904 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -30,6 +30,7 @@ use std::collections::BTreeMap; use gridfpv_events::{AdapterId, CompetitorRef, Event, Pass, SourceTime}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// Identifies a competitor *within a single timing source*. /// @@ -38,7 +39,8 @@ use serde::{Deserialize, Serialize}; /// timer), so laps are grouped on the `(AdapterId, CompetitorRef)` pair. Binding /// these per-source competitors to a single GridFPV pilot is a later registration /// concern (Architecture §9) and deliberately out of scope here. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct CompetitorKey { /// The timing source the competitor belongs to. pub adapter: AdapterId, @@ -57,7 +59,8 @@ impl CompetitorKey { } /// A single completed lap: the interval between two consecutive lap-gate passes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct Lap { /// 1-based lap number within the competitor's run. pub number: u32, @@ -67,7 +70,8 @@ pub struct Lap { } /// Every lap a single competitor completed, in order. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct CompetitorLaps { /// Which source-local competitor these laps belong to. pub competitor: CompetitorKey, @@ -96,7 +100,8 @@ impl CompetitorLaps { /// /// Competitors are ordered deterministically by [`CompetitorKey`] so the /// projection is stable across runs regardless of event arrival order. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] pub struct LapList { /// Per-competitor laps, ordered by competitor key. pub competitors: Vec, diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs new file mode 100644 index 0000000..b90fb83 --- /dev/null +++ b/crates/server/src/control.rs @@ -0,0 +1,316 @@ +//! The RD control path (protocol.html §5): the privileged commands the Race Director +//! issues, and the acknowledgements they get back. +//! +//! Control is **authenticated and Director-local** (§5): race control, marshaling, and +//! scheduling require the RD's authenticated role and run only against the Director — +//! the Cloud exposes no control path at all. This module is the *command vocabulary*; +//! the auth that gates it (#44) and the axum endpoints that carry it (#45) are later +//! issues. +//! +//! Every [`Command`] maps to an action the engine/marshaling layer already models — a +//! heat-loop transition ([`HeatTransition`](gridfpv_events::HeatTransition)), a +//! schedule ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)), a +//! registration binding, or one of the five marshaling adjudications. A command is a +//! *request* to append the corresponding event(s); the engine validates legality +//! against current state and answers with a [`CommandAck`]. +//! +//! > **Note (scope/addressing deferred):** commands address heats and competitors by +//! > the ids the event model already uses ([`HeatId`](gridfpv_events::HeatId), +//! > [`CompetitorRef`](gridfpv_events::CompetitorRef), +//! > [`LogRef`](gridfpv_events::LogRef)). The richer scope grammar and any +//! > event/class addressing on commands (protocol.html §9.6) are refined alongside the +//! > control endpoints (#45) and the doc-reconciliation pass. + +use gridfpv_events::{AdapterId, CompetitorRef, HeatId, LogRef, Penalty, SourceTime}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::error::ProtocolError; + +/// A privileged RD control command (protocol.html §5). Externally tagged like the +/// event model, so it maps to a TS discriminated union. +/// +/// The variants fall into four groups: +/// +/// - **Heat-loop transitions** — [`Stage`](Command::Stage), [`Arm`](Command::Arm), +/// [`Start`](Command::Start), [`Finish`](Command::Finish), [`Score`](Command::Score), +/// [`Advance`](Command::Advance), and the off-ramps [`Abort`](Command::Abort), +/// [`Restart`](Command::Restart), [`Discard`](Command::Discard). Each requests the +/// matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine validates +/// it against the heat's current state (race-engine.html §2). +/// - **Scheduling** — [`ScheduleHeat`](Command::ScheduleHeat) creates a heat with its +/// lineup ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)). +/// - **Registration** — [`Register`](Command::Register) binds a source-local +/// competitor to a pilot (the binding the adapter never does itself; Architecture §9). +/// - **Marshaling adjudications** — the five corrections +/// ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap), +/// [`AdjustLap`](Command::AdjustLap), [`VoidHeat`](Command::VoidHeat), +/// [`ApplyPenalty`](Command::ApplyPenalty)), each requesting the corresponding +/// marshaling event the projection folds in (never a mutation; architecture.html §3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum Command { + // --- Heat-loop transitions (race-engine.html §2) --- + /// Begin staging the heat — countdown starts, frequencies are assigned. + Stage { + /// The heat to transition. + heat: HeatId, + }, + /// Arm the heat — open the gate to detections. + Arm { + /// The heat to transition. + heat: HeatId, + }, + /// Start the heat running — passes are consumed from here. + Start { + /// The heat to transition. + heat: HeatId, + }, + /// Finish the heat — close the race (time elapsed or all landed). + Finish { + /// The heat to transition. + heat: HeatId, + }, + /// Score the heat — finalize the result. + Score { + /// The heat to transition. + heat: HeatId, + }, + /// Advance the scored heat — hand its result to the format generator. + Advance { + /// The heat to transition. + heat: HeatId, + }, + /// Abort the heat — abandon it before scoring (an off-ramp). + Abort { + /// The heat to transition. + heat: HeatId, + }, + /// Restart a running heat — back to staging for a re-run. + Restart { + /// The heat to transition. + heat: HeatId, + }, + /// Discard a scored heat — drop its result for a re-run. + Discard { + /// The heat to transition. + heat: HeatId, + }, + + // --- Scheduling --- + /// Create a heat with its lineup (`Event::HeatScheduled`). Seat/frequency + /// assignment is the scheduler's concern; this commits who is in the heat. + ScheduleHeat { + /// The id the new heat will carry. + heat: HeatId, + /// The competitors in the heat, in lineup order. + lineup: Vec, + }, + + // --- Registration --- + /// Bind a source-local competitor to a GridFPV pilot (Architecture §9) — the + /// registration the adapter never performs. The pilot handle is the event-scoped + /// [`PilotId`](crate::scope::PilotId). + Register { + /// The timing source the competitor belongs to. + adapter: AdapterId, + /// The source-local competitor handle being bound. + competitor: CompetitorRef, + /// The event-scoped pilot the competitor is bound to. + pilot: crate::scope::PilotId, + }, + + // --- Marshaling adjudications (architecture.html §3, the five corrections) --- + /// Void a previously-detected pass, referenced by its log offset + /// (`Event::DetectionVoided`). + VoidDetection { + /// The log offset of the pass (or ruling) to void. + target: LogRef, + }, + /// Insert a lap-gate pass the timer missed (`Event::LapInserted`). + InsertLap { + /// The timing source to attribute the inserted pass to. + adapter: AdapterId, + /// The competitor the inserted lap belongs to. + competitor: CompetitorRef, + /// When the inserted crossing happened, on the source clock. + at: SourceTime, + }, + /// Re-time a previously-detected pass (`Event::LapAdjusted`). + AdjustLap { + /// The log offset of the pass to re-time. + target: LogRef, + /// The corrected crossing time, on the source clock. + at: SourceTime, + }, + /// Void an entire heat (`Event::HeatVoided`). + VoidHeat { + /// The heat to void. + heat: HeatId, + }, + /// Apply a penalty to a competitor in a heat (`Event::PenaltyApplied`). + ApplyPenalty { + /// The heat the penalty applies in. + heat: HeatId, + /// The competitor penalized. + competitor: CompetitorRef, + /// The penalty applied. + penalty: Penalty, + }, +} + +/// The acknowledgement of a [`Command`] (protocol.html §5): commands up, +/// acknowledgements down. +/// +/// `ok` is the success flag; on failure `error` carries the single shared +/// [`ProtocolError`](crate::error::ProtocolError) (§9.8) — an illegal transition for +/// the heat's state, an unauthorized caller, an unknown heat. On success `error` is +/// `None`. (The resulting projection state flows back separately as +/// [`ChangeEnvelope`](crate::stream::ChangeEnvelope)s on the read stream, not in the +/// ack.) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CommandAck { + /// Whether the command was accepted and applied. + pub ok: bool, + /// The failure detail when `ok` is `false`; `None` on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +impl CommandAck { + /// A successful acknowledgement. + pub fn ok() -> Self { + Self { + ok: true, + error: None, + } + } + + /// A failed acknowledgement carrying the error detail. + pub fn failed(error: ProtocolError) -> Self { + Self { + ok: false, + error: Some(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ErrorCode; + use crate::scope::PilotId; + + #[test] + fn heat_transition_commands_round_trip() { + let commands = vec![ + Command::Stage { + heat: HeatId("q-1".into()), + }, + Command::Arm { + heat: HeatId("q-1".into()), + }, + Command::Start { + heat: HeatId("q-1".into()), + }, + Command::Finish { + heat: HeatId("q-1".into()), + }, + Command::Score { + heat: HeatId("q-1".into()), + }, + Command::Advance { + heat: HeatId("q-1".into()), + }, + Command::Abort { + heat: HeatId("q-1".into()), + }, + Command::Restart { + heat: HeatId("q-1".into()), + }, + Command::Discard { + heat: HeatId("q-1".into()), + }, + ]; + for command in commands { + let json = serde_json::to_string(&command).unwrap(); + let back: Command = serde_json::from_str(&json).unwrap(); + assert_eq!(command, back); + } + } + + #[test] + fn scheduling_and_registration_round_trip() { + let commands = vec![ + Command::ScheduleHeat { + heat: HeatId("q-1".into()), + lineup: vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ], + }, + Command::Register { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("acroace".into()), + }, + ]; + for command in commands { + let json = serde_json::to_string(&command).unwrap(); + let back: Command = serde_json::from_str(&json).unwrap(); + assert_eq!(command, back); + } + } + + #[test] + fn all_five_marshaling_adjudications_round_trip() { + let commands = vec![ + Command::VoidDetection { target: LogRef(42) }, + Command::InsertLap { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(5_000_000), + }, + Command::AdjustLap { + target: LogRef(43), + at: SourceTime::from_micros(5_100_000), + }, + Command::VoidHeat { + heat: HeatId("q-1".into()), + }, + Command::ApplyPenalty { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 2_000_000 }, + }, + ]; + for command in commands { + let json = serde_json::to_string(&command).unwrap(); + let back: Command = serde_json::from_str(&json).unwrap(); + assert_eq!(command, back); + } + } + + #[test] + fn command_ack_ok_omits_error() { + let ack = CommandAck::ok(); + let json = serde_json::to_string(&ack).unwrap(); + assert!(!json.contains("error"), "ok ack omits error: {json}"); + let back: CommandAck = serde_json::from_str(&json).unwrap(); + assert_eq!(ack, back); + } + + #[test] + fn command_ack_failed_carries_the_error() { + let ack = CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + "cannot Arm a heat that is not Staged", + )); + let json = serde_json::to_string(&ack).unwrap(); + let back: CommandAck = serde_json::from_str(&json).unwrap(); + assert_eq!(ack, back); + assert!(!ack.ok); + } +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..aa3d8f8 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,102 @@ +//! The single shared error shape (protocol.html §9.8, "Error model"). +//! +//! The contract uses **one** error type across every path — HTTP snapshot reads, the +//! WS change stream, and control acknowledgements — defined once in Rust like the +//! rest of the wire contract. A client therefore handles auth failure, an unknown +//! scope, a stale cursor, or a version mismatch through a single uniform shape rather +//! than a different envelope per surface. +//! +//! The exact field set is "pinned at implementation" (§9.8); this is the minimal +//! shape every surface needs — a machine-readable [`ErrorCode`] plus a human-readable +//! `message` — with room to grow additively (a new code, an optional detail field) +//! without breaking older clients. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// A machine-readable error category — the cases protocol.html §9.8 enumerates +/// ("auth failure, unknown scope, stale-cursor, version-mismatch"), plus a generic +/// fallback. Externally tagged like the event model so it maps to a TS string union. +/// +/// Adding a new variant is an additive change (§7): an older client that does not +/// understand a new code treats it as it would [`ErrorCode::Internal`] — an error it +/// surfaces but cannot specifically branch on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ErrorCode { + /// The caller is not authenticated, or not authorized for this scope / action + /// (protocol.html §5). Covers a missing/invalid read token and an unprivileged + /// client attempting the control path. + Unauthorized, + /// The requested [`Scope`](crate::scope::Scope) does not exist or addresses + /// nothing the server can serve (an unknown heat id, a pilot not in the event). + UnknownScope, + /// The client presented a resume [`Cursor`](crate::stream::Cursor) older than the + /// server's retained window (protocol.html §3): the gap cannot be replayed and the + /// client must **re-snapshot**. The distinguished "go re-snapshot" signal. + StaleCursor, + /// The client's [`ContractVersion`](crate::ContractVersion) falls outside the band + /// the server serves (protocol.html §7) — the "too old / too new, please refresh" + /// signal as an error on a path that cannot carry a [`ServerHello`](crate::ServerHello). + VersionMismatch, + /// The request was malformed — an unparseable body, an illegal command for the + /// current state, a bad scope expression. + BadRequest, + /// An unexpected server-side failure. The catch-all an older client also maps an + /// unknown future code onto. + Internal, +} + +/// The one error shape every protocol surface returns (protocol.html §9.8). +/// +/// Carried in an HTTP error body, a WS error frame, and a failed +/// [`CommandAck`](crate::control::CommandAck) alike. `code` is the branchable +/// category; `message` is the human-readable detail for logs and the RD console. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ProtocolError { + /// The machine-readable error category. + pub code: ErrorCode, + /// A human-readable description of what went wrong. + pub message: String, +} + +impl ProtocolError { + /// Build an error from a code and a message. + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_error_round_trips() { + let err = ProtocolError::new(ErrorCode::StaleCursor, "cursor 12 is below the window"); + let json = serde_json::to_string(&err).unwrap(); + let back: ProtocolError = serde_json::from_str(&json).unwrap(); + assert_eq!(err, back); + } + + #[test] + fn every_error_code_round_trips() { + use ErrorCode::*; + for code in [ + Unauthorized, + UnknownScope, + StaleCursor, + VersionMismatch, + BadRequest, + Internal, + ] { + let json = serde_json::to_string(&code).unwrap(); + let back: ErrorCode = serde_json::from_str(&json).unwrap(); + assert_eq!(code, back); + } + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 394e27a..59d9ed7 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2,4 +2,158 @@ //! stream) plus the RD control path, served over axum on the Director (and reused //! by the Cloud). The wire types are defined here once and generated to TypeScript //! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html. +//! +//! # What this crate is (and is not), as of #40 +//! +//! This is the **wire-type surface only** — the Rust types that *are* the protocol +//! (protocol.html §6: "the contract defined once, in Rust"). There is **no transport +//! here yet**: the axum HTTP/WS server (snapshot endpoint #42, change stream #43, +//! auth #44, control #45) is built on top of these types in later issues. Defining +//! the types first means every one of those issues — and the frontend protocol +//! client (#49) — builds against a single generated contract that already exists. +//! +//! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format) +//! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring +//! the event model in `gridfpv-events`. `cargo xtask gen` runs the ts-rs export tests +//! across every crate that exports (events, projection, engine, server) with +//! `TS_RS_EXPORT_DIR` pinned to the repo root, so all `.ts` files land in +//! `/bindings/`; `cargo xtask ci` drift-checks them. +//! +//! # The shape of the contract (protocol.html §2–§7) +//! +//! 1. A client connects and announces the [`ContractVersion`] it was built against in +//! a [`Hello`] (§7); the server replies with the band it serves ([`ServerHello`]). +//! 2. It fetches a [`Snapshot`](snapshot::Snapshot) of a [`Scope`](scope::Scope) — the +//! current materialized projection plus the [`Cursor`](stream::Cursor) the stream +//! resumes from (§2). +//! 3. It subscribes ([`SubscribeRequest`](scope::SubscribeRequest)) and receives an +//! ordered run of [`ChangeEnvelope`](stream::ChangeEnvelope)s, each a per-projection +//! delta-or-fresh-value carrying the monotonic [`Cursor`](stream::Cursor) (§3). +//! 4. The RD additionally drives the control path: [`Command`](control::Command)s up, +//! [`CommandAck`](control::CommandAck)s down (§5). +//! +//! Any failure on any of those paths is the single shared +//! [`ProtocolError`](error::ProtocolError) (§9.8). +//! +//! # Deferred (later issues + the doc-reconciliation pass) +//! +//! - **Exact delta encodings.** [`Change::Delta`](stream::Change::Delta) is a +//! `serde_json::Value` placeholder for now; the precise per-projection delta shapes +//! (a single appended lap, a heat-state transition, …) are pinned when the change +//! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed +//! here, which is what later issues need. +//! - **Scope addressing grammar.** [`Scope`](scope::Scope) enumerates the four +//! addressable resources (event/class/heat/pilot, §4); the full filter/grammar and +//! URL shapes (§9.6) are refined when the snapshot endpoint lands (#42). +//! - **`LiveRaceState`** ([`snapshot::LiveRaceState`]) is a real-but-minimal +//! placeholder; #41 fills in live lap/split progress, running order, and on-deck. +//! - **Auth tokens / sessions.** The credential format (§9.4) is a #44 concern; no +//! token types live here yet. #![forbid(unsafe_code)] + +pub mod control; +pub mod error; +pub mod scope; +pub mod snapshot; +pub mod stream; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// The protocol **contract version** — axis 1 of the three version axes +/// (protocol.html §7): the wire shapes owned by this crate, kept distinct from the +/// log/schema version and the projection version. +/// +/// A single monotonic integer. A breaking change to any wire type bumps it; additive +/// changes (new projections, new fields an older client ignores) do not. The version +/// is negotiated at connect time ([`Hello`] / [`ServerHello`]) and is independent of +/// the transport (LAN or Cloud) — see [`CONTRACT_VERSION`] for the version this build +/// speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/", as = "u32")] +pub struct ContractVersion { + /// The monotonic contract version number. + pub version: u32, +} + +impl ContractVersion { + /// Construct from a raw version number. + pub const fn new(version: u32) -> Self { + Self { version } + } +} + +/// The contract version this server build speaks. The first wire contract is `1`. +pub const CONTRACT_VERSION: ContractVersion = ContractVersion::new(1); + +/// The client's **connect message** (protocol.html §7): announced before anything +/// else, it carries the [`ContractVersion`] the client was built against so the +/// server can decide whether it can serve it. +/// +/// "The contract version is negotiated, not assumed" (§7): the client states its +/// version, the server replies with the band it serves ([`ServerHello`]). The version +/// is the only thing negotiated here; auth (a bearer token, §9.4) is a separate #44 +/// concern layered in front, not part of this message yet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct Hello { + /// The contract version the client was built against. + pub contract_version: ContractVersion, +} + +/// The server's reply to a [`Hello`] (protocol.html §7). +/// +/// The server "states the version(s) it serves" as an inclusive band +/// `[min_contract_version, max_contract_version]`. A client whose +/// [`Hello::contract_version`] falls inside the band is served; one that falls +/// outside gets `compatible = false` — the explicit "too old / too new, please +/// refresh" signal — rather than malformed data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ServerHello { + /// The oldest contract version this server still serves (inclusive). + pub min_contract_version: ContractVersion, + /// The newest contract version this server serves (inclusive). + pub max_contract_version: ContractVersion, + /// Whether the client's announced version falls within the served band. `false` + /// is the "please refresh" signal (the client is too old or too new to be served). + pub compatible: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_version_is_a_bare_integer_on_the_wire() { + // `#[serde(transparent)]`: the version serialises as the raw integer, and + // `#[ts(as = "u32")]` mirrors that as a `number` in the generated TS. + assert_eq!( + serde_json::to_string(&ContractVersion::new(7)).unwrap(), + "7" + ); + } + + #[test] + fn hello_round_trips() { + let hello = Hello { + contract_version: CONTRACT_VERSION, + }; + let json = serde_json::to_string(&hello).unwrap(); + let back: Hello = serde_json::from_str(&json).unwrap(); + assert_eq!(hello, back); + } + + #[test] + fn server_hello_round_trips() { + let reply = ServerHello { + min_contract_version: ContractVersion::new(1), + max_contract_version: ContractVersion::new(3), + compatible: true, + }; + let json = serde_json::to_string(&reply).unwrap(); + let back: ServerHello = serde_json::from_str(&json).unwrap(); + assert_eq!(reply, back); + } +} diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs new file mode 100644 index 0000000..3ada256 --- /dev/null +++ b/crates/server/src/scope.rs @@ -0,0 +1,164 @@ +//! Subscription **scoping** (protocol.html §4). +//! +//! A subscription is a [`Scope`] plus a starting [`Cursor`](crate::stream::Cursor): +//! the scope is how a client asks for exactly the projections it needs — "a phone +//! fetches one heat, not the whole event" — and bounds both payload size and what a +//! client is allowed to see. The same [`Scope`] addresses a snapshot read (§2) and a +//! stream subscription (§3). +//! +//! protocol.html §9.6 resolves the *grammar* (a scope grammar with multi-scope +//! subscribe and cursor pagination) but defers the exact addressing scheme, URL +//! shapes, and filter expressiveness to implementation. This enum is the **four +//! addressable resources** §4 names (event / class / heat / pilot); the richer filter +//! grammar and composition ("a pilot within a class") layer on when the snapshot +//! endpoint lands (#42). + +use gridfpv_events::HeatId; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Identifies an **event** — the whole competition (protocol.html §4 "Event scope"). +/// +/// A transparent string newtype like the event-model ids. The cross-event registry +/// and account model are a Cloud concern (protocol.html callout); this is just the +/// stable handle a scope addresses, sufficient for the wire contract. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct EventId(pub String); + +/// Identifies a **class** within an event (protocol.html §4 "Class scope") — one +/// class's phases, schedule, and standings, which may run in parallel with others. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct ClassId(pub String); + +/// Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer +/// following their own laps, results, and next heat. +/// +/// This is the event-scoped pilot handle a scope addresses, distinct from the +/// per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a +/// pilot is bound to source competitors by a registration action (Architecture §9), +/// which is out of scope here. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct PilotId(pub String); + +/// What a subscription addresses (protocol.html §4): one of the four resources a +/// client can scope to. Externally tagged like the event model, so it maps to a TS +/// discriminated union. +/// +/// Scopes compose and a client may hold several at once over one connection (§4); a +/// multi-scope subscribe is a list of these. The richer filter grammar (§9.6) is +/// deferred — this fixes the addressable *resources*, which is what later issues build +/// the grammar over. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum Scope { + /// Everything for one event — the broad default for the "whole venue screen" and + /// the Cloud live surface. + Event { + /// The event addressed. + event: EventId, + }, + /// One class's phases, schedule, and standings. + Class { + /// The event the class belongs to. + event: EventId, + /// The class addressed. + class: ClassId, + }, + /// One heat's live race-state and lap lists — the tightest, lowest-latency scope. + Heat { + /// The heat addressed (the id it carries in the log). + heat: HeatId, + }, + /// One pilot across the event — their laps, results, and next heat. + Pilot { + /// The event the pilot is racing in. + event: EventId, + /// The pilot addressed. + pilot: PilotId, + }, +} + +/// A subscription request (protocol.html §3, §4): a [`Scope`] plus where to resume +/// the stream from. +/// +/// `from` is the last-applied [`Cursor`](crate::stream::Cursor) the client presents on +/// (re)connect — `Some(cursor)` to resume after a drop, `None` for a fresh +/// subscription that begins from the snapshot's cursor (protocol.html §3 "resume by +/// cursor, fall back to re-snapshot"). If the gap is too old to replay the server +/// answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the +/// client re-snapshots. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SubscribeRequest { + /// The resource this subscription is scoped to. + pub scope: Scope, + /// The cursor to resume after, or `None` to start fresh from the snapshot. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub from: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stream::Cursor; + + #[test] + fn every_scope_variant_round_trips() { + let scopes = vec![ + Scope::Event { + event: EventId("spring-cup".into()), + }, + Scope::Class { + event: EventId("spring-cup".into()), + class: ClassId("open".into()), + }, + Scope::Heat { + heat: HeatId("q-1".into()), + }, + Scope::Pilot { + event: EventId("spring-cup".into()), + pilot: PilotId("acroace".into()), + }, + ]; + for scope in scopes { + let json = serde_json::to_string(&scope).unwrap(); + let back: Scope = serde_json::from_str(&json).unwrap(); + assert_eq!(scope, back); + } + } + + #[test] + fn subscribe_request_round_trips_with_and_without_cursor() { + let fresh = SubscribeRequest { + scope: Scope::Heat { + heat: HeatId("q-1".into()), + }, + from: None, + }; + let json = serde_json::to_string(&fresh).unwrap(); + // A fresh subscribe omits `from` entirely (skip_serializing_if). + assert!( + !json.contains("from"), + "fresh subscribe omits cursor: {json}" + ); + let back: SubscribeRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(fresh, back); + + let resume = SubscribeRequest { + scope: Scope::Event { + event: EventId("spring-cup".into()), + }, + from: Some(Cursor::new(42)), + }; + let json = serde_json::to_string(&resume).unwrap(); + let back: SubscribeRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(resume, back); + } +} diff --git a/crates/server/src/snapshot.rs b/crates/server/src/snapshot.rs new file mode 100644 index 0000000..653d937 --- /dev/null +++ b/crates/server/src/snapshot.rs @@ -0,0 +1,215 @@ +//! The snapshot read model (protocol.html §2) and the served projection bodies (§1). +//! +//! A client begins by fetching a [`Snapshot`]: the current materialized state of the +//! projection it scoped to, plus the [`Cursor`](crate::stream::Cursor) the change +//! stream resumes from. "Snapshot first, then subscribe" — the snapshot is +//! self-contained and immediately renderable, and its cursor lets the stream start +//! exactly where the snapshot ended so nothing is missed or double-applied (§2, §3). +//! +//! # What the protocol serves — projections, not the log (§1) +//! +//! The wire never carries the raw event log; it carries **projections** — the answers +//! derived by folding the log. [`ProjectionBody`] is the closed set of served +//! projection shapes, each reusing the *existing* projection/engine output type rather +//! than redefining it (protocol.html §1: this doc "does not redefine the projections +//! themselves"): +//! +//! - [`LiveRaceState`] — the latency-sensitive live core (a #41 placeholder here); +//! - [`LapList`](gridfpv_projection::LapList) — per-pilot laps, marshaling already +//! folded in; +//! - [`HeatResult`](gridfpv_engine::scoring::HeatResult) — the scored heat outcome; +//! - ranking — a qualifying / bracket [`RankEntry`](gridfpv_engine::format::RankEntry) +//! list (the provisional-or-final ordering a format exposes); +//! - [`EventOutcome`](gridfpv_engine::event::EventOutcome) — the full-event standings. +//! +//! [`ProjectionKind`] is the bare discriminant (which projection, no body) — what a +//! [`ChangeEnvelope`](crate::stream::ChangeEnvelope) names when it carries a *delta* +//! rather than a fresh value. + +use gridfpv_engine::event::EventOutcome; +use gridfpv_engine::format::RankEntry; +use gridfpv_engine::scoring::HeatResult; +use gridfpv_events::HeatId; +use gridfpv_projection::LapList; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::stream::Cursor; + +/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive +/// core every overlay and spectator watches: the current heat and its loop state, the +/// active pilots' live lap/split progress, the running order, and the on-deck heat. +/// +/// **#41 placeholder.** This is intentionally minimal-but-real: it carries the current +/// heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the +/// change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot +/// live progress, the running order, and the on-deck heat. Keeping the type real (not a +/// unit stub) means #41 extends fields additively (§7) without reshaping the envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct LiveRaceState { + /// The heat currently on the timer, if any (`None` between heats). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub current_heat: Option, + /// The current heat's loop phase (protocol.html §1, race-engine.html §2). + pub phase: HeatPhase, +} + +/// The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged → +/// Armed → Running → Landed → Scored`). +/// +/// This is the *projected* view of the heat loop — the folded current phase a client +/// renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the +/// engine appends. The off-ramp transitions (abort/restart/discard) resolve back onto +/// one of these phases, so the live view stays a simple linear status. A #41-era detail +/// the placeholder pins minimally. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum HeatPhase { + /// The heat exists with a lineup but has not begun. + Scheduled, + /// Countdown begun; frequencies assigned. + Staged, + /// The gate is open to detections. + Armed, + /// The race is running; passes are being consumed. + Running, + /// The race has closed — time elapsed or all landed — but is not yet scored. + Landed, + /// The result is finalized. + Scored, +} + +/// Which projection a snapshot body or change envelope is *about* — the bare +/// discriminant with no payload (protocol.html §3). +/// +/// A fresh-value envelope carries the whole [`ProjectionBody`] (kind *and* value); a +/// delta envelope carries this `ProjectionKind` plus an opaque delta, naming the +/// projection it advances without re-sending it. Keeping the kind separate from the +/// body lets a delta name its target cheaply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ProjectionKind { + /// The live race-state ([`LiveRaceState`]). + LiveRaceState, + /// A lap list ([`LapList`](gridfpv_projection::LapList)). + LapList, + /// A scored heat result ([`HeatResult`](gridfpv_engine::scoring::HeatResult)). + HeatResult, + /// A qualifying / bracket ranking (a [`RankEntry`](gridfpv_engine::format::RankEntry) list). + Ranking, + /// The full-event outcome / standings ([`EventOutcome`](gridfpv_engine::event::EventOutcome)). + EventOutcome, +} + +/// A served projection **with its value** (protocol.html §1) — the closed set of +/// projection shapes the contract carries, each reusing the existing projection/engine +/// output type. Externally tagged, so it maps to a TS discriminated union keyed by +/// projection kind. +/// +/// This is the body of a [`Snapshot`] and of a fresh-value +/// [`ChangeEnvelope`](crate::stream::ChangeEnvelope). Adding a new served projection is +/// an additive variant (§7); an older client ignores a kind it does not understand. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ProjectionBody { + /// The live race-state (protocol.html §1, the latency-sensitive core). + LiveRaceState(LiveRaceState), + /// A per-pilot lap list, with marshaling adjudications already folded in. + LapList(LapList), + /// A scored heat result — finishing order, lap counts, deciding metrics. + HeatResult(HeatResult), + /// A qualifying or bracket ranking (best first; provisional mid-format, final once + /// the format completes). + Ranking(Vec), + /// The full-event outcome: the qualifying ranking, bracket standings, and winner. + EventOutcome(EventOutcome), +} + +impl ProjectionBody { + /// The [`ProjectionKind`] discriminant for this body — what a delta envelope names. + pub fn kind(&self) -> ProjectionKind { + match self { + ProjectionBody::LiveRaceState(_) => ProjectionKind::LiveRaceState, + ProjectionBody::LapList(_) => ProjectionKind::LapList, + ProjectionBody::HeatResult(_) => ProjectionKind::HeatResult, + ProjectionBody::Ranking(_) => ProjectionKind::Ranking, + ProjectionBody::EventOutcome(_) => ProjectionKind::EventOutcome, + } + } +} + +/// A snapshot of a scoped projection (protocol.html §2): the current materialized +/// [`ProjectionBody`] plus the [`Cursor`] the change stream resumes from. +/// +/// "Snapshot first, then subscribe" (§2): the client renders the `body` immediately and +/// opens the stream `from` this `cursor`, so the first envelope it applies is exactly +/// the one after the snapshot — nothing missed, nothing double-applied. Re-snapshotting +/// is always correct because projections are recomputable; the stream is an +/// optimization, never the source of truth (§3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct Snapshot { + /// The stream cursor this snapshot was taken at — where the subscription resumes. + pub cursor: Cursor, + /// The materialized projection at that cursor. + pub body: ProjectionBody, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_live() -> ProjectionBody { + ProjectionBody::LiveRaceState(LiveRaceState { + current_heat: Some(HeatId("q-1".into())), + phase: HeatPhase::Running, + }) + } + + #[test] + fn snapshot_round_trips() { + let snap = Snapshot { + cursor: Cursor::new(7), + body: sample_live(), + }; + let json = serde_json::to_string(&snap).unwrap(); + let back: Snapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snap, back); + } + + #[test] + fn empty_lap_list_body_round_trips() { + let snap = Snapshot { + cursor: Cursor::new(0), + body: ProjectionBody::LapList(LapList::default()), + }; + let json = serde_json::to_string(&snap).unwrap(); + let back: Snapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snap, back); + } + + #[test] + fn body_kind_matches_variant() { + assert_eq!(sample_live().kind(), ProjectionKind::LiveRaceState); + assert_eq!( + ProjectionBody::Ranking(vec![]).kind(), + ProjectionKind::Ranking + ); + assert_eq!( + ProjectionBody::LapList(LapList::default()).kind(), + ProjectionKind::LapList + ); + } + + #[test] + fn projection_kind_round_trips() { + use ProjectionKind::*; + for kind in [LiveRaceState, LapList, HeatResult, Ranking, EventOutcome] { + let json = serde_json::to_string(&kind).unwrap(); + let back: ProjectionKind = serde_json::from_str(&json).unwrap(); + assert_eq!(kind, back); + } + } +} diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs new file mode 100644 index 0000000..66b4086 --- /dev/null +++ b/crates/server/src/stream.rs @@ -0,0 +1,142 @@ +//! The realtime change stream (protocol.html §3): the per-stream [`Cursor`] and the +//! [`ChangeEnvelope`]s that advance a client's projections after the snapshot. +//! +//! After fetching a [`Snapshot`](crate::snapshot::Snapshot), the client opens a +//! WebSocket and receives an ordered run of change envelopes. "Snapshot + incremental +//! changes" (§3): the stream sends **deltas, not repeated full state** — a new lap, a +//! heat-state transition, a re-scored result — and may collapse a burst into a single +//! fresh-value envelope when a delta would be larger or ambiguous (e.g. after a +//! marshaling re-fold of a whole lap list). +//! +//! Each envelope names the projection it touches (via the +//! [`ProjectionKind`](crate::snapshot::ProjectionKind) for a delta, or the +//! [`ProjectionBody`](crate::snapshot::ProjectionBody) itself for a fresh value) and +//! carries the monotonic [`Cursor`] the client applies in order. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::snapshot::{ProjectionBody, ProjectionKind}; + +/// The public **projection sequence number** (protocol.html §3, §9.5): a single +/// monotonic integer per stream, increasing by one per envelope. +/// +/// It is *not* the raw log offset — one log append can fan out into several projection +/// changes or none a client subscribes to. The protocol commits to this projection +/// sequence as its public ordering; the log offset stays a private Director/Cloud +/// detail. The snapshot hands the client a starting cursor (§2) and every envelope +/// advances it; on reconnect the client presents its last-applied cursor to resume +/// (§3). +/// +/// Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint` +/// (a `u64` exceeds JS's safe-integer range), matching how it serialises. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/", as = "u64")] +pub struct Cursor { + /// The monotonic per-stream sequence value. + pub seq: u64, +} + +impl Cursor { + /// Construct a cursor from a raw sequence value. + pub const fn new(seq: u64) -> Self { + Self { seq } + } + + /// The next cursor in sequence (`seq + 1`) — the envelope that follows this one. + pub const fn next(self) -> Self { + Self { seq: self.seq + 1 } + } +} + +/// How an envelope conveys a projection change (protocol.html §3, §9.2): a **delta** +/// for append-heavy projections (lap lists, live state) or a **fresh value** for cheap +/// or re-folded ones (a heat result, a ranking after a marshaling correction). +/// +/// Externally tagged, mapping to a TS discriminated union. +/// +/// > **Deferred (#43):** the exact per-projection delta *encodings* are a placeholder. +/// > [`Change::Delta`] carries a `serde_json::Value` rather than a typed delta for each +/// > [`ProjectionKind`]; the precise delta shapes (a single appended lap, a heat-state +/// > transition) are pinned when the change stream lands. The *structure* — the +/// > per-projection delta-vs-fresh-value distinction protocol.html §9.2 fixes — is +/// > what matters now and is captured here. A fresh value, by contrast, is already a +/// > fully-typed [`ProjectionBody`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum Change { + /// An incremental delta against the projection named by the envelope's + /// [`ChangeEnvelope::projection`]. Placeholder encoding (`serde_json::Value`) until + /// #43 pins the typed per-projection delta shapes. + Delta(#[ts(type = "unknown")] serde_json::Value), + /// The projection's complete new value — used when a delta would be larger or + /// ambiguous (a marshaling re-fold, a cheap projection). Fully typed already. + FreshValue(ProjectionBody), +} + +/// A change envelope (protocol.html §3): one ordered, sequenced update to a single +/// projection. +/// +/// Each envelope carries its monotonic `sequence` [`Cursor`], names the `projection` it +/// touches (as a [`ProjectionKind`] — for a [`Change::FreshValue`] the body also +/// carries the kind, but naming it here keeps deltas and fresh values uniform), and a +/// `change` that is a delta or a fresh value. The client applies envelopes in strictly +/// increasing `sequence` order; re-delivering one already applied is a no-op keyed by +/// sequence (§3 "idempotent application", "at-least-once, deduplicated"). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ChangeEnvelope { + /// This envelope's position in the per-stream sequence. + pub sequence: Cursor, + /// Which projection this change advances. + pub projection: ProjectionKind, + /// The delta or fresh value to apply. + pub change: Change, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snapshot::{HeatPhase, LiveRaceState}; + use gridfpv_events::HeatId; + + #[test] + fn cursor_is_a_bare_integer_on_the_wire() { + // Transparent newtype: serialises as the raw sequence integer. + assert_eq!(serde_json::to_string(&Cursor::new(42)).unwrap(), "42"); + } + + #[test] + fn cursor_next_increments() { + assert_eq!(Cursor::new(5).next(), Cursor::new(6)); + } + + #[test] + fn fresh_value_envelope_round_trips() { + let env = ChangeEnvelope { + sequence: Cursor::new(101), + projection: ProjectionKind::LiveRaceState, + change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState { + current_heat: Some(HeatId("q-1".into())), + phase: HeatPhase::Running, + })), + }; + let json = serde_json::to_string(&env).unwrap(); + let back: ChangeEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(env, back); + } + + #[test] + fn delta_envelope_round_trips_with_placeholder_payload() { + // The delta is an opaque JSON value until #43 pins typed per-projection deltas. + let env = ChangeEnvelope { + sequence: Cursor::new(102), + projection: ProjectionKind::LapList, + change: Change::Delta(serde_json::json!({ "appended_lap": { "number": 3 } })), + }; + let json = serde_json::to_string(&env).unwrap(); + let back: ChangeEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(env, back); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c74f6fa..084d79b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -66,22 +66,21 @@ fn test() -> bool { ) } -/// Regenerate the Rust→TypeScript bindings (#4). +/// Regenerate the Rust→TypeScript bindings (#4, #40). /// /// ts-rs exports its types from a generated `#[test]` per `#[ts(export)]` type, so -/// "generation" is just running the events crate's export tests: each derived `TS` -/// impl writes its `.ts` file. `TS_RS_EXPORT_DIR` pins the base directory to the -/// workspace root, so every `export_to = "bindings/"` lands in `/bindings/`. +/// "generation" is just running the export tests of every crate that derives `TS`: +/// each derived impl writes its `.ts` file. The wire contract now spans four crates — +/// the event model (`gridfpv-events`), the served lap projection (`gridfpv-projection`), +/// the heat results / rankings / event outcome (`gridfpv-engine`), and the protocol +/// wire types themselves (`gridfpv-server`) — so we run the `export_bindings` filter +/// across the whole workspace. `TS_RS_EXPORT_DIR` pins the base directory to the +/// workspace root, so every `export_to = "bindings/"` lands in `/bindings/` +/// regardless of which crate the type lives in. fn gen_bindings(root: &Path) -> bool { run_env( "cargo", - &[ - "test", - "--package", - "gridfpv-events", - "--quiet", - "export_bindings", - ], + &["test", "--workspace", "--quiet", "export_bindings"], &[("TS_RS_EXPORT_DIR", root)], ) } From edc5ae7c6b99047e11c1154a82fecd0ce2265008 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 03:54:59 +0000 Subject: [PATCH 004/362] Add live race-state projection and axum snapshot endpoints (#41, #42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out the live race-state projection (#41) and stand up the axum snapshot HTTP transport over the wire types (#42) — the protocol server's READ foundation. #41 live race-state projection (crates/server/src/live_state.rs): - `LiveRaceState` grows additively over the #40 placeholder: current heat + HeatPhase, active pilots (lineup), per-pilot `PilotProgress` (laps + last lap), provisional running order, and the on-deck heat. - `live_state(events: &[Event]) -> LiveRaceState` is a pure fold of the log: current heat = most-recently-active heat, phase via `heat::heat_state`, progress via the marshaling-aware lap projection, running order by most-laps-then-earliest-last-lap. Re-exported from `snapshot` so `ProjectionBody::LiveRaceState` still names a snapshot-module type. #42 axum snapshot endpoints + scope addressing (crates/server/src/app.rs): - `AppState` holds the shared append-only log behind `Arc>`; `EventSource` is an object-safe facade over `EventLog` (whose generic `append_batch` is not dyn-compatible), blanket-impl'd for every backend. The WS stream (#43) and control path (#45) share this same handle (read tail / append exposed already). - `router(state) -> axum::Router` serves a snapshot per scope: GET /snapshot/event/{event}, /snapshot/class/{event}/{class}, /snapshot/heat/{heat} (?projection=live|laps|result), /snapshot/pilot/{event}/{pilot}. Each returns `Snapshot { cursor, body }` where cursor = log length at read time (the resume point) and body = the scoped projection. The heat scope filters the log to the heat's window. - `ProtocolError` gains an axum `IntoResponse` mapping code -> HTTP status. Tests: live_state unit tests (Scheduled..Scored progression, lap progress, running order, on-deck) + axum integration tests via tower oneshot (no network/Docker) asserting each scope's body + cursor against an InMemoryLog. Regenerated bindings (LiveRaceState.ts, new PilotProgress.ts). axum + tokio added to the server crate's own Cargo.toml. `cargo xtask ci` is green. Part of #41, #42. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 95 +++++ bindings/LiveRaceState.ts | 41 +- bindings/PilotProgress.ts | 24 ++ crates/server/Cargo.toml | 11 + crates/server/src/app.rs | 677 ++++++++++++++++++++++++++++++++ crates/server/src/lib.rs | 28 +- crates/server/src/live_state.rs | 439 +++++++++++++++++++++ crates/server/src/snapshot.rs | 26 +- crates/server/src/stream.rs | 1 + 9 files changed, 1299 insertions(+), 43 deletions(-) create mode 100644 bindings/PilotProgress.ts create mode 100644 crates/server/src/app.rs create mode 100644 crates/server/src/live_state.rs diff --git a/Cargo.lock b/Cargo.lock index 2731ebc..0a87e71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,58 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -441,11 +493,16 @@ dependencies = [ name = "gridfpv-server" version = "0.1.0" dependencies = [ + "axum", "gridfpv-engine", "gridfpv-events", "gridfpv-projection", + "gridfpv-storage", + "http-body-util", "serde", "serde_json", + "tokio", + "tower", "ts-rs", ] @@ -549,6 +606,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -563,6 +626,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -805,6 +869,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.2" @@ -1279,6 +1349,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1489,9 +1570,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -1552,6 +1645,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1590,6 +1684,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts index 57d8b82..137e5b8 100644 --- a/bindings/LiveRaceState.ts +++ b/bindings/LiveRaceState.ts @@ -1,24 +1,45 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorRef } from "./CompetitorRef"; import type { HeatId } from "./HeatId"; import type { HeatPhase } from "./HeatPhase"; +import type { PilotProgress } from "./PilotProgress"; /** * The current **live race-state** projection (protocol.html §1) — the latency-sensitive - * core every overlay and spectator watches: the current heat and its loop state, the - * active pilots' live lap/split progress, the running order, and the on-deck heat. + * core every overlay and spectator watches. * - * **#41 placeholder.** This is intentionally minimal-but-real: it carries the current - * heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the - * change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot - * live progress, the running order, and the on-deck heat. Keeping the type real (not a - * unit stub) means #41 extends fields additively (§7) without reshaping the envelope. + * Fleshed out in #41 from the #40 placeholder: it carries the current heat and its + * [`HeatPhase`], the active pilots in that heat, each pilot's live + * [`PilotProgress`], the running order, and the on-deck heat. Fields are additive over + * the placeholder (§7), so the snapshot body and change envelope did not reshape. */ export type LiveRaceState = { /** - * The heat currently on the timer, if any (`None` between heats). + * The heat currently on the timer, if any (`None` before any heat is scheduled). */ current_heat?: HeatId, /** - * The current heat's loop phase (protocol.html §1, race-engine.html §2). + * The current heat's loop phase (protocol.html §1, race-engine.html §2). When + * `current_heat` is `None` this is [`HeatPhase::Scheduled`] (the idle default). */ -phase: HeatPhase, }; +phase: HeatPhase, +/** + * The active pilots in the current heat — its lineup, in lineup (seeding) order. + * Empty when there is no current heat. + */ +active_pilots?: Array, +/** + * Per-pilot live lap progress for the current heat, one entry per active pilot, + * ordered like [`active_pilots`](Self::active_pilots). + */ +progress?: Array, +/** + * The provisional running order of the current heat: the active pilots ranked by + * live standing (most laps, then who banked their last lap earliest). Best first. + */ +running_order?: Array, +/** + * The next heat to run after the current one (the earliest still-`Scheduled` heat + * that is not on the timer), if one is known. + */ +on_deck?: HeatId, }; diff --git a/bindings/PilotProgress.ts b/bindings/PilotProgress.ts new file mode 100644 index 0000000..057a9c3 --- /dev/null +++ b/bindings/PilotProgress.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompetitorRef } from "./CompetitorRef"; + +/** + * One active pilot's live progress in the current heat (protocol.html §1). + * + * Derived from the heat's lap projection: the number of laps completed so far and the + * duration of the most recent completed lap (the live "last lap" an overlay shows). + * Splits are a later refinement; the lap-count + last-lap pair is the live core. + */ +export type PilotProgress = { +/** + * The source-local competitor this progress is for (a member of the lineup). + */ +competitor: CompetitorRef, +/** + * Completed laps so far in the heat. + */ +laps_completed: number, +/** + * Duration (µs, source clock) of the most recently completed lap, or `None` before + * the pilot has completed a lap. + */ +last_lap_micros?: bigint, }; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index b04e293..fc68248 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -10,8 +10,19 @@ rust-version.workspace = true gridfpv-events.workspace = true gridfpv-projection.workspace = true gridfpv-engine.workspace = true +gridfpv-storage.workspace = true serde.workspace = true serde_json.workspace = true ts-rs = "12" +# The snapshot HTTP transport (#42). The WS change stream (#43) and the control path +# (#45) build on the same axum `Router` and `AppState` defined in `app.rs`. +axum = "0.8" +tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] } + [dev-dependencies] +# `tower::ServiceExt::oneshot` drives the router in integration tests with no real +# network or Docker; `http-body-util` collects the response body to assert on it. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs new file mode 100644 index 0000000..9821cad --- /dev/null +++ b/crates/server/src/app.rs @@ -0,0 +1,677 @@ +//! The axum snapshot HTTP transport (protocol.html §2, §4) — issue #42. +//! +//! This is the first real transport over the wire types: a [`Router`] of `GET` snapshot +//! endpoints, one addressing scheme per [`Scope`], each returning a [`Snapshot`] of the +//! scoped projection plus the [`Cursor`] the change stream resumes from. "Snapshot first, +//! then subscribe" (§2): the client renders the body immediately and opens the WS stream +//! (#43) *from* the returned cursor. +//! +//! # [`AppState`] — the shared event source +//! +//! Every path the server serves reads (and the control path #45 writes) the **one** +//! append-only [`EventLog`]. [`AppState`] wraps it in an `Arc>` so it can be +//! cloned into every axum handler and, later, shared with the WS stream task and the +//! control handler: +//! +//! - **Reads** (this issue): a handler locks, reads the log into a `Vec`, folds +//! the requested projection, and unlocks. The cursor is the log length at read time +//! (see below). +//! - **Writes** (#45): the control handler will lock and `append` through the same +//! handle, and the change stream (#43) will observe the new tail. Holding the log +//! behind a single mutex keeps reads and the eventual writes serialized against one +//! another with no torn state. +//! +//! The log is stored as `Arc>` so any backend +//! ([`InMemoryLog`](gridfpv_storage::InMemoryLog), +//! [`SqliteLog`](gridfpv_storage::SqliteLog)) drops in unchanged. +//! +//! # Cursor semantics +//! +//! The [`Cursor`] a snapshot returns is **the log length at read time** — i.e. the offset +//! the *next* appended event will receive (`EventLog::len`). That is exactly the resume +//! point: a subscription opened `from` this cursor begins at the first event appended +//! after the snapshot, so nothing is missed or double-applied (§2, §3). The public +//! projection-sequence cursor the WS stream advances (#43) is seeded from this value. +//! (Mapping log offsets to the per-projection sequence number §9.5 is a #43 concern; for +//! the snapshot the log length *is* the resume cursor.) +//! +//! # Scope addressing (protocol.html §4, §9.6) +//! +//! §4 fixes the four addressable resources (event / class / heat / pilot); §9.6 defers the +//! exact URL grammar to implementation. This module pins a concrete REST addressing over +//! those four, which the doc-reconciliation pass refines: +//! +//! | scope | route | body | +//! |-------|-------|------| +//! | event | `GET /snapshot/event/{event}` | [`LiveRaceState`] over the whole log | +//! | class | `GET /snapshot/class/{event}/{class}` | [`LiveRaceState`] (class filtering deferred — see below) | +//! | heat | `GET /snapshot/heat/{heat}` | [`LiveRaceState`] for that heat, or — with `?projection=laps` / `?projection=result` — its [`LapList`] / [`HeatResult`] | +//! | pilot | `GET /snapshot/pilot/{event}/{pilot}` | the pilot's [`LapList`] (their laps across the event) | +//! +//! A single connection may hold several scopes at once (§4); the multi-scope *subscribe* +//! is a stream concern (#43). The heat scope is the tightest, lowest-latency one and the +//! one with a precise log filter; the broader event/class scopes fold the whole log. +//! +//! ## Deferred filtering (model gaps, not protocol gaps) +//! +//! The raw event log (`gridfpv-events`) has **no event-id or class-id** on its events — +//! one Director serves one event, and the class→heat and pilot→competitor mappings are +//! scheduler / registration concerns (#36, Architecture §9) not yet in the log. So: +//! +//! - **Event scope** serves the whole log's live state (correct: the log *is* one event). +//! - **Class scope** is addressable and serves live state, but cannot yet *filter* the log +//! to one class — there is no class tag to filter on. It returns the same whole-event +//! live state; precise class filtering lands when the schedule model carries class tags. +//! - **Pilot scope** filters the lap projection to the competitor whose ref equals the +//! `pilot` id (the registration binding that maps a `PilotId` to source competitors is +//! out of scope here, Architecture §9; until it lands the pilot id is matched against the +//! competitor ref directly). +//! +//! These are noted so #43/#44/#45 build on a stable addressing surface while the log-level +//! filters are tightened later. + +use std::sync::{Arc, Mutex}; + +use axum::Json; +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use gridfpv_engine::scoring::{WinCondition, score_events}; +use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; +use gridfpv_projection::{LapList, lap_list_marshaled}; +use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; +use serde::Deserialize; + +use crate::error::{ErrorCode, ProtocolError}; +use crate::live_state::live_state; +use crate::scope::{ClassId, EventId, PilotId}; +use crate::snapshot::{ProjectionBody, Snapshot}; +use crate::stream::Cursor; + +/// The object-safe slice of [`EventLog`] the protocol transport needs: read the whole +/// log, read its tail, append, and report its length. +/// +/// [`EventLog`] itself is **not** dyn-compatible (its `append_batch` is generic over the +/// iterator type), so it cannot be stored behind a trait object directly. This facade +/// exposes exactly the operations the snapshot reads (and the future control writes, #45, +/// and stream tail, #43) need, and is blanket-implemented for every `EventLog`, so any +/// backend ([`InMemoryLog`](gridfpv_storage::InMemoryLog), +/// [`SqliteLog`](gridfpv_storage::SqliteLog)) drops in behind one `Arc>`. +pub trait EventSource { + /// Read every entry in append order (the snapshot folds these into a projection). + fn read_all(&self) -> StorageResult>; + /// Read every entry from `start` (inclusive) to the end — the WS stream tail (#43). + fn read_from(&self, start: Offset) -> StorageResult>; + /// Append a single event, returning its assigned offset — the control path (#45). + fn append(&mut self, event: Event, recorded_at: Option) -> StorageResult; + /// The number of entries — equivalently the offset the next append receives, which is + /// the snapshot resume [`Cursor`]. + fn len(&self) -> StorageResult; + /// Whether the log has no entries. + fn is_empty(&self) -> StorageResult { + Ok(self.len()? == 0) + } +} + +impl EventSource for L { + fn read_all(&self) -> StorageResult> { + EventLog::read_all(self) + } + fn read_from(&self, start: Offset) -> StorageResult> { + EventLog::read_from(self, start) + } + fn append(&mut self, event: Event, recorded_at: Option) -> StorageResult { + EventLog::append(self, event, recorded_at) + } + fn len(&self) -> StorageResult { + EventLog::len(self) + } +} + +/// A thread-safe handle to the one append-only event log every protocol path shares. +/// +/// `Send` (not `Send + Sync`) is required on the trait object because it lives behind a +/// [`Mutex`]; the `Arc>` makes the whole handle `Send + Sync` so axum can store +/// it in [`AppState`] and clone it into every handler. The object-safe [`EventSource`] +/// facade lets any [`EventLog`] backend sit behind it. +pub type SharedLog = Arc>; + +/// The shared application state every axum handler is given (protocol.html §2). +/// +/// Holds the [`SharedLog`] — the single source of truth the snapshot reads fold, the WS +/// stream (#43) tails, and the control path (#45) appends through. Cloning an `AppState` +/// clones the `Arc`, so all handlers and future tasks share one log. +#[derive(Clone)] +pub struct AppState { + log: SharedLog, +} + +impl AppState { + /// Build the state from a concrete log backend (e.g. an + /// [`InMemoryLog`](gridfpv_storage::InMemoryLog) or + /// [`SqliteLog`](gridfpv_storage::SqliteLog)). + pub fn new(log: impl EventLog + Send + 'static) -> Self { + Self { + log: Arc::new(Mutex::new(log)), + } + } + + /// Build the state from an already-shared log handle — for when the WS stream (#43) + /// or control path (#45) needs to share the *same* `Arc>` with the router. + pub fn from_shared(log: SharedLog) -> Self { + Self { log } + } + + /// The shared log handle, for tasks that tail or append outside the router. + pub fn log(&self) -> SharedLog { + Arc::clone(&self.log) + } + + /// Read the whole log into a `Vec` plus the resume [`Cursor`] (the log length + /// at read time). A single lock spans the read so the events and the cursor are + /// consistent with one another. + fn read(&self) -> Result<(Vec, Cursor), ProtocolError> { + let log = self.log.lock().map_err(|_| { + ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned") + })?; + let stored = log + .read_all() + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?; + let cursor = Cursor::new( + log.len() + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?, + ); + let events = stored.into_iter().map(|s| s.event).collect(); + Ok((events, cursor)) + } +} + +/// Build the snapshot [`Router`] (protocol.html §2, §4) over the shared [`AppState`]. +/// +/// The four scope addressing schemes (see the module docs) plus a liveness `GET /health`. +/// The WS stream (#43), auth middleware (#44), and control routes (#45) layer onto this +/// same router and state. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(|| async { "ok" })) + .route("/snapshot/event/{event}", get(snapshot_event)) + .route("/snapshot/class/{event}/{class}", get(snapshot_class)) + .route("/snapshot/heat/{heat}", get(snapshot_heat)) + .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot)) + .with_state(state) +} + +/// The projection a heat-scope snapshot returns. Selected by `?projection=…`; defaults to +/// the live race-state. +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +enum HeatProjection { + /// The live race-state for the heat (default). + #[default] + Live, + /// The heat's per-pilot [`LapList`]. + Laps, + /// The heat's scored [`HeatResult`]. + Result, +} + +/// Query parameters for the heat-scope endpoint. +#[derive(Debug, Default, Deserialize)] +struct HeatQuery { + #[serde(default)] + projection: HeatProjection, +} + +/// `GET /snapshot/event/{event}` — the whole event's live race-state (§4 event scope). +async fn snapshot_event( + State(state): State, + Path(_event): Path, +) -> Result, ProtocolError> { + let (events, cursor) = state.read()?; + Ok(Json(Snapshot { + cursor, + body: ProjectionBody::LiveRaceState(live_state(&events)), + })) +} + +/// `GET /snapshot/class/{event}/{class}` — a class's live race-state (§4 class scope). +/// +/// Class-level *filtering* of the log is deferred (the log carries no class tag yet — see +/// the module docs); this serves the whole-event live state under the class address so the +/// scope is reachable now and tightens later without an addressing change. +async fn snapshot_class( + State(state): State, + Path((_event, _class)): Path<(EventId, ClassId)>, +) -> Result, ProtocolError> { + let (events, cursor) = state.read()?; + Ok(Json(Snapshot { + cursor, + body: ProjectionBody::LiveRaceState(live_state(&events)), + })) +} + +/// `GET /snapshot/heat/{heat}` — the tightest scope (§4 heat scope). +/// +/// `?projection=live` (default) returns the heat's [`LiveRaceState`]; `?projection=laps` +/// its [`LapList`]; `?projection=result` its scored [`HeatResult`]. The log is filtered to +/// the heat's window so the body is heat-local. +async fn snapshot_heat( + State(state): State, + Path(heat): Path, + Query(query): Query, +) -> Result, ProtocolError> { + let (events, cursor) = state.read()?; + + // The heat must exist in the log (a `HeatScheduled` for this id), else UnknownScope. + let scheduled = events + .iter() + .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if *h == heat)); + if !scheduled { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no heat scheduled with id {:?}", heat.0), + )); + } + + let heat_events = heat_window(&events, &heat); + + let body = match query.projection { + HeatProjection::Live => { + // Fold the heat's window into live state (it is the only heat present, so it + // is the current one). + ProjectionBody::LiveRaceState(live_state(&heat_events)) + } + HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( + heat_events.iter().enumerate().map(|(i, e)| (i as u64, e)), + )), + HeatProjection::Result => { + // The win condition is heat / format config not carried in the raw log; the + // snapshot scores the heat's passes under a neutral best-lap qualifying rule + // so the result body is populated. The authoritative per-heat win condition is + // applied by the engine when scoring is driven (#45); refining the served + // result to the configured condition is part of that work. + let race_start = heat_events + .iter() + .filter_map(first_pass_at) + .min() + .unwrap_or(SourceTime::from_micros(0)); + ProjectionBody::HeatResult(score_events( + &heat_events, + WinCondition::BestLap, + race_start, + )) + } + }; + + Ok(Json(Snapshot { cursor, body })) +} + +/// `GET /snapshot/pilot/{event}/{pilot}` — a pilot's laps across the event (§4 pilot scope). +/// +/// Filters the lap projection to the competitor whose ref equals the `pilot` id (the +/// registration binding mapping a `PilotId` to source competitors is out of scope here — +/// see the module docs). +async fn snapshot_pilot( + State(state): State, + Path((_event, pilot)): Path<(EventId, PilotId)>, +) -> Result, ProtocolError> { + let (events, cursor) = state.read()?; + + let full = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + let pilot_ref = CompetitorRef(pilot.0.clone()); + let competitors: Vec<_> = full + .competitors + .into_iter() + .filter(|c| c.competitor.competitor == pilot_ref) + .collect(); + + if competitors.is_empty() { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no laps for pilot {:?} in this event", pilot.0), + )); + } + + Ok(Json(Snapshot { + cursor, + body: ProjectionBody::LapList(LapList { competitors }), + })) +} + +/// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start. +fn first_pass_at(event: &Event) -> Option { + match event { + Event::Pass(p) if p.gate.is_lap_gate() => Some(p.at), + _ => None, + } +} + +/// Filter the log to a single heat's window: that heat's scheduling / state-change events, +/// plus all passes and marshaling adjudications that fall *while the heat is the active +/// one*. +/// +/// With one heat per log in the common case this is the whole log; with several heats it +/// scopes passes to the span between this heat's first scheduling/transition and the next +/// heat's. Passes carry no heat id (they are raw observations), so attribution is by +/// position in the log relative to heat-loop events — the same ordering the engine uses to +/// decide which heat consumes a pass (race-engine.html §2). +fn heat_window(events: &[Event], heat: &HeatId) -> Vec { + let mut window = Vec::new(); + // `active` tracks whether the cursor is currently inside this heat's span: it opens on + // a heat-loop event for `heat` and closes on a heat-loop event for a *different* heat. + let mut active = false; + for event in events { + match event { + Event::HeatScheduled { heat: h, .. } | Event::HeatStateChanged { heat: h, .. } => { + active = h == heat; + if active { + window.push(event.clone()); + } + } + // Passes and adjudications belong to whichever heat is currently active. + _ if active => window.push(event.clone()), + _ => {} + } + } + window +} + +/// Render a [`ProtocolError`] as an HTTP error response (protocol.html §9.8): the JSON +/// error body under the status its [`ErrorCode`] maps to. The single shared error shape is +/// returned uniformly across every snapshot path. +impl IntoResponse for ProtocolError { + fn into_response(self) -> Response { + let status = match self.code { + ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED, + ErrorCode::UnknownScope => StatusCode::NOT_FOUND, + ErrorCode::StaleCursor => StatusCode::GONE, + ErrorCode::VersionMismatch => StatusCode::UPGRADE_REQUIRED, + ErrorCode::BadRequest => StatusCode::BAD_REQUEST, + ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(self)).into_response() + } +} + +// `EventId` / `ClassId` / `PilotId` / `HeatId` are transparent string newtypes, so axum's +// `Path` extractor deserializes a single path segment straight into them via serde. + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass}; + use gridfpv_projection::CompetitorKey; + use gridfpv_storage::InMemoryLog; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use crate::snapshot::HeatPhase; + + fn pass(competitor: &str, at: i64, seq: u64) -> Event { + Event::Pass(Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef(competitor.into()), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + }) + } + + /// A recorded heat log: q-1 scheduled, run through to Scored, with laps for A and B. + fn recorded_heat() -> Vec { + vec![ + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + }, + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Staged, + }, + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Armed, + }, + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + pass("A", 1_000_000, 1), + pass("B", 1_500_000, 1), + pass("A", 4_000_000, 2), // A lap 1 = 3.0s + pass("B", 5_500_000, 2), // B lap 1 = 4.0s + pass("A", 6_500_000, 3), // A lap 2 = 2.5s + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Finished, + }, + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Scored, + }, + ] + } + + fn state_with(events: Vec) -> (AppState, u64) { + let mut log = InMemoryLog::default(); + for e in &events { + EventLog::append(&mut log, e.clone(), None).unwrap(); + } + let len = events.len() as u64; + (AppState::new(log), len) + } + + async fn get_snapshot(state: AppState, uri: &str) -> (StatusCode, Option) { + let response = router(state) + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let snap = serde_json::from_slice::(&bytes).ok(); + (status, snap) + } + + #[tokio::test] + async fn event_scope_returns_live_state_and_cursor() { + let (state, len) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await; + assert_eq!(status, StatusCode::OK); + let snap = snap.unwrap(); + // The cursor is the log length at read time — the resume point. + assert_eq!(snap.cursor, Cursor::new(len)); + match snap.body { + ProjectionBody::LiveRaceState(ls) => { + assert_eq!(ls.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(ls.phase, HeatPhase::Scored); + assert_eq!( + ls.active_pilots, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())] + ); + // A leads (2 laps vs 1). + assert_eq!(ls.running_order.first(), Some(&CompetitorRef("A".into()))); + } + other => panic!("expected live state, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_default_is_live_state() { + let (state, len) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1").await; + assert_eq!(status, StatusCode::OK); + let snap = snap.unwrap(); + assert_eq!(snap.cursor, Cursor::new(len)); + assert!(matches!(snap.body, ProjectionBody::LiveRaceState(_))); + } + + #[tokio::test] + async fn heat_scope_laps_projection_returns_lap_list() { + let (state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=laps").await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::LapList(laps) => { + let a = laps + .competitor(&CompetitorKey { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + }) + .unwrap(); + assert_eq!(a.lap_count(), 2); + } + other => panic!("expected lap list, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_result_projection_returns_heat_result() { + let (state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=result").await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::HeatResult(result) => { + // Both A and B placed. + assert_eq!(result.places.len(), 2); + } + other => panic!("expected heat result, got {other:?}"), + } + } + + #[tokio::test] + async fn unknown_heat_is_not_found() { + let (state, _) = state_with(recorded_heat()); + let response = router(state) + .oneshot( + Request::builder() + .uri("/snapshot/heat/does-not-exist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let err: ProtocolError = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(err.code, ErrorCode::UnknownScope); + } + + #[tokio::test] + async fn pilot_scope_filters_to_the_pilot_laps() { + let (state, len) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/pilot/spring-cup/A").await; + assert_eq!(status, StatusCode::OK); + let snap = snap.unwrap(); + assert_eq!(snap.cursor, Cursor::new(len)); + match snap.body { + ProjectionBody::LapList(laps) => { + // Only pilot A's laps, not B's. + assert_eq!(laps.competitors.len(), 1); + assert_eq!( + laps.competitors[0].competitor.competitor, + CompetitorRef("A".into()) + ); + assert_eq!(laps.competitors[0].lap_count(), 2); + } + other => panic!("expected lap list, got {other:?}"), + } + } + + #[tokio::test] + async fn unknown_pilot_is_not_found() { + let (state, _) = state_with(recorded_heat()); + let response = router(state) + .oneshot( + Request::builder() + .uri("/snapshot/pilot/spring-cup/nobody") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn class_scope_is_reachable() { + let (state, len) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/snapshot/class/spring-cup/open").await; + assert_eq!(status, StatusCode::OK); + let snap = snap.unwrap(); + assert_eq!(snap.cursor, Cursor::new(len)); + assert!(matches!(snap.body, ProjectionBody::LiveRaceState(_))); + } + + #[tokio::test] + async fn empty_log_event_scope_is_idle_with_zero_cursor() { + let (state, _) = state_with(vec![]); + let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await; + assert_eq!(status, StatusCode::OK); + let snap = snap.unwrap(); + assert_eq!(snap.cursor, Cursor::new(0)); + match snap.body { + ProjectionBody::LiveRaceState(ls) => assert_eq!(ls.current_heat, None), + other => panic!("expected idle live state, got {other:?}"), + } + } + + #[tokio::test] + async fn two_heats_scope_to_their_own_windows() { + // Two heats in one log; the heat scope must filter to its own passes. + let events = vec![ + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + }, + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), // q-1: A one lap + Event::HeatScheduled { + heat: HeatId("q-2".into()), + lineup: vec![CompetitorRef("B".into())], + }, + Event::HeatStateChanged { + heat: HeatId("q-2".into()), + transition: HeatTransition::Running, + }, + pass("B", 10_000_000, 1), + pass("B", 13_000_000, 2), + pass("B", 15_000_000, 3), // q-2: B two laps + ]; + let (state, _) = state_with(events); + + let (_, snap) = get_snapshot(state.clone(), "/snapshot/heat/q-1?projection=laps").await; + match snap.unwrap().body { + ProjectionBody::LapList(laps) => { + // Only A appears in q-1's window. + assert_eq!(laps.competitors.len(), 1); + assert_eq!( + laps.competitors[0].competitor.competitor, + CompetitorRef("A".into()) + ); + } + other => panic!("expected lap list, got {other:?}"), + } + + let (_, snap) = get_snapshot(state, "/snapshot/heat/q-2?projection=laps").await; + match snap.unwrap().body { + ProjectionBody::LapList(laps) => { + assert_eq!(laps.competitors.len(), 1); + assert_eq!( + laps.competitors[0].competitor.competitor, + CompetitorRef("B".into()) + ); + assert_eq!(laps.competitors[0].lap_count(), 2); + } + other => panic!("expected lap list, got {other:?}"), + } + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 59d9ed7..bc1220f 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -3,14 +3,15 @@ //! by the Cloud). The wire types are defined here once and generated to TypeScript //! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html. //! -//! # What this crate is (and is not), as of #40 +//! # What this crate is, as of #41 / #42 //! -//! This is the **wire-type surface only** — the Rust types that *are* the protocol -//! (protocol.html §6: "the contract defined once, in Rust"). There is **no transport -//! here yet**: the axum HTTP/WS server (snapshot endpoint #42, change stream #43, -//! auth #44, control #45) is built on top of these types in later issues. Defining -//! the types first means every one of those issues — and the frontend protocol -//! client (#49) — builds against a single generated contract that already exists. +//! The wire-type surface — the Rust types that *are* the protocol (protocol.html §6: +//! "the contract defined once, in Rust") — plus the **read transport** over them: the +//! live race-state projection ([`live_state`], #41) and the axum snapshot endpoints +//! ([`app`], #42). The change stream (#43), auth (#44), and control endpoints (#45) +//! build on the same [`app::AppState`] and [`app::router`]. Defining the types first +//! means every one of those issues — and the frontend protocol client (#49) — builds +//! against a single generated contract that already exists. //! //! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format) //! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring @@ -42,17 +43,20 @@ //! (a single appended lap, a heat-state transition, …) are pinned when the change //! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed //! here, which is what later issues need. -//! - **Scope addressing grammar.** [`Scope`](scope::Scope) enumerates the four -//! addressable resources (event/class/heat/pilot, §4); the full filter/grammar and -//! URL shapes (§9.6) are refined when the snapshot endpoint lands (#42). -//! - **`LiveRaceState`** ([`snapshot::LiveRaceState`]) is a real-but-minimal -//! placeholder; #41 fills in live lap/split progress, running order, and on-deck. +//! - **Scope addressing grammar.** [`app::router`] pins a concrete REST addressing over +//! the four resources (event/class/heat/pilot, §4); the richer filter grammar (§9.6), +//! and the log-level *class* filter (the log carries no class tag yet), are refined in +//! the doc-reconciliation pass and when the schedule model grows class tags. +//! - **Split-level live progress.** [`live_state::LiveRaceState`] carries lap-count and +//! last-lap progress; per-gate split progress is a later refinement. //! - **Auth tokens / sessions.** The credential format (§9.4) is a #44 concern; no //! token types live here yet. #![forbid(unsafe_code)] +pub mod app; pub mod control; pub mod error; +pub mod live_state; pub mod scope; pub mod snapshot; pub mod stream; diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs new file mode 100644 index 0000000..8134720 --- /dev/null +++ b/crates/server/src/live_state.rs @@ -0,0 +1,439 @@ +//! The live race-state projection (protocol.html §1) — issue #41. +//! +//! This is the latency-sensitive core every overlay and spectator watches: the heat +//! currently on the timer and its loop [`HeatPhase`], the active pilots in that heat, +//! each pilot's live lap progress, the running order, and the on-deck (next scheduled) +//! heat. It is a **pure fold of the event log** ([`live_state`]): given the same events +//! it always produces the same [`LiveRaceState`], so a recorded session replays +//! identically and the snapshot is recomputable (protocol.html §2–§3). +//! +//! # What "current heat" means here +//! +//! The event log carries [`Event::HeatScheduled`] and +//! [`Event::HeatStateChanged`](gridfpv_events::Event::HeatStateChanged) for every heat +//! (race-engine.html §2). The **current heat** is the most-recently-active one: the heat +//! whose latest state-changing event appears last in the log and that is not yet +//! `Scored`/`Advanced` past the timer. We resolve it by scanning the log in order and +//! tracking the last heat to receive a `HeatScheduled` or `HeatStateChanged`. A heat +//! that has reached the terminal `Scored` phase is still reported as the current heat +//! until a *newer* heat takes the timer (a freshly-scheduled or transitioned heat), +//! which mirrors what an overlay shows between heats ("last heat, now scored"). +//! +//! # On-deck +//! +//! The **on-deck** heat is the next `Scheduled` heat that is not the current one and has +//! not yet run — the heat the RD will stage next. With no schedule metadata in the raw +//! log (seat/frequency assignment lands later, #36) this is simply the earliest-scheduled +//! heat still sitting in `Scheduled` that isn't already on the timer. +//! +//! # Live progress and running order +//! +//! Per-pilot live progress reuses the existing lap projection +//! ([`gridfpv_projection::lap_list_marshaled`]) filtered to the current heat's lineup, so +//! marshaling adjudications already fold in. The **running order** ranks the active pilots +//! by laps completed (descending) then by the completion time of their last lap (earliest +//! first) — the same "most laps, then who banked the last lap first" rule the scorer uses +//! mid-heat (race-engine.html §7.4), but derived without a win condition (which is heat / +//! format config not present in the raw log). It is therefore a *provisional* live order, +//! not the scored result; the authoritative scored ranking is the +//! [`HeatResult`](gridfpv_engine::scoring::HeatResult) projection. + +use std::collections::BTreeMap; + +use gridfpv_engine::heat::{HeatState, heat_state}; +use gridfpv_events::{CompetitorRef, Event, HeatId}; +use gridfpv_projection::{CompetitorKey, lap_list_marshaled}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::snapshot::HeatPhase; + +/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive +/// core every overlay and spectator watches. +/// +/// Fleshed out in #41 from the #40 placeholder: it carries the current heat and its +/// [`HeatPhase`], the active pilots in that heat, each pilot's live +/// [`PilotProgress`], the running order, and the on-deck heat. Fields are additive over +/// the placeholder (§7), so the snapshot body and change envelope did not reshape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct LiveRaceState { + /// The heat currently on the timer, if any (`None` before any heat is scheduled). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub current_heat: Option, + /// The current heat's loop phase (protocol.html §1, race-engine.html §2). When + /// `current_heat` is `None` this is [`HeatPhase::Scheduled`] (the idle default). + pub phase: HeatPhase, + /// The active pilots in the current heat — its lineup, in lineup (seeding) order. + /// Empty when there is no current heat. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_pilots: Vec, + /// Per-pilot live lap progress for the current heat, one entry per active pilot, + /// ordered like [`active_pilots`](Self::active_pilots). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub progress: Vec, + /// The provisional running order of the current heat: the active pilots ranked by + /// live standing (most laps, then who banked their last lap earliest). Best first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub running_order: Vec, + /// The next heat to run after the current one (the earliest still-`Scheduled` heat + /// that is not on the timer), if one is known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub on_deck: Option, +} + +impl Default for LiveRaceState { + /// The idle live state: no current heat, `Scheduled` phase, nothing active. + fn default() -> Self { + Self { + current_heat: None, + phase: HeatPhase::Scheduled, + active_pilots: Vec::new(), + progress: Vec::new(), + running_order: Vec::new(), + on_deck: None, + } + } +} + +/// One active pilot's live progress in the current heat (protocol.html §1). +/// +/// Derived from the heat's lap projection: the number of laps completed so far and the +/// duration of the most recent completed lap (the live "last lap" an overlay shows). +/// Splits are a later refinement; the lap-count + last-lap pair is the live core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct PilotProgress { + /// The source-local competitor this progress is for (a member of the lineup). + pub competitor: CompetitorRef, + /// Completed laps so far in the heat. + pub laps_completed: u32, + /// Duration (µs, source clock) of the most recently completed lap, or `None` before + /// the pilot has completed a lap. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub last_lap_micros: Option, +} + +/// Fold the event log into the [`LiveRaceState`] (protocol.html §1) — issue #41. +/// +/// Pure and order-preserving: scans `events` once to find the current heat (the +/// most-recently-active one, see the module docs), folds its [`HeatState`] into a +/// [`HeatPhase`], reads its lineup, derives each active pilot's [`PilotProgress`] from the +/// (marshaling-aware) lap projection, ranks them into a running order, and finds the +/// on-deck heat. Replaying the same log twice yields the same state. +pub fn live_state(events: &[Event]) -> LiveRaceState { + let Some(current_heat) = current_heat(events) else { + return LiveRaceState::default(); + }; + + let phase = heat_state(events, ¤t_heat) + .map(phase_of) + .unwrap_or(HeatPhase::Scheduled); + + let active_pilots = lineup_of(events, ¤t_heat); + + // The lap projection is keyed on (adapter, competitor); the lineup carries only the + // competitor handle. Fold the whole log once (marshaling-aware) and index laps by + // competitor ref, summing across adapters for a competitor seen on more than one. + let laps = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + let mut by_ref: BTreeMap<&CompetitorRef, (u32, Option)> = BTreeMap::new(); + for cl in &laps.competitors { + let CompetitorKey { competitor, .. } = &cl.competitor; + let entry = by_ref.entry(competitor).or_insert((0, None)); + entry.0 += cl.lap_count() as u32; + entry.1 = cl.laps.last().map(|l| l.duration_micros).or(entry.1); + } + + let progress: Vec = active_pilots + .iter() + .map(|competitor| { + let (laps_completed, last_lap_micros) = + by_ref.get(competitor).copied().unwrap_or((0, None)); + PilotProgress { + competitor: competitor.clone(), + laps_completed, + last_lap_micros, + } + }) + .collect(); + + let running_order = running_order(&progress); + + LiveRaceState { + current_heat: Some(current_heat.clone()), + phase, + active_pilots, + progress, + running_order, + on_deck: on_deck(events, ¤t_heat), + } +} + +/// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. +fn phase_of(state: HeatState) -> HeatPhase { + match state { + HeatState::Scheduled => HeatPhase::Scheduled, + HeatState::Staged => HeatPhase::Staged, + HeatState::Armed => HeatPhase::Armed, + HeatState::Running => HeatPhase::Running, + HeatState::Finished => HeatPhase::Landed, + HeatState::Scored => HeatPhase::Scored, + } +} + +/// The current heat: the heat whose latest `HeatScheduled` / `HeatStateChanged` event +/// appears last in the log. `None` if no heat was ever scheduled. +fn current_heat(events: &[Event]) -> Option { + let mut current: Option = None; + for event in events { + match event { + Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } => { + current = Some(heat.clone()); + } + _ => {} + } + } + current +} + +/// The lineup of a heat: the competitors from its most recent `HeatScheduled`. +fn lineup_of(events: &[Event], heat: &HeatId) -> Vec { + let mut lineup = Vec::new(); + for event in events { + if let Event::HeatScheduled { heat: h, lineup: l } = event { + if h == heat { + lineup = l.clone(); + } + } + } + lineup +} + +/// The on-deck heat: the earliest still-`Scheduled` heat that is not the current one. +/// +/// "Still scheduled" means its folded [`HeatState`] is `Scheduled` (it has been created +/// but not staged). Heats are considered in the order they were first scheduled in the +/// log, so the on-deck heat is the next one queued behind the current heat. +fn on_deck(events: &[Event], current: &HeatId) -> Option { + let mut seen: Vec = Vec::new(); + for event in events { + if let Event::HeatScheduled { heat, .. } = event { + if !seen.contains(heat) { + seen.push(heat.clone()); + } + } + } + seen.into_iter() + .find(|heat| heat != current && heat_state(events, heat) == Some(HeatState::Scheduled)) +} + +/// Rank active pilots into the provisional running order: most laps first, ties broken +/// by the shorter last-lap time (a proxy for who is pacing ahead), then by competitor +/// ref for a total, deterministic order. +fn running_order(progress: &[PilotProgress]) -> Vec { + let mut order: Vec<&PilotProgress> = progress.iter().collect(); + order.sort_by(|a, b| { + b.laps_completed + .cmp(&a.laps_completed) + .then_with(|| match (a.last_lap_micros, b.last_lap_micros) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }) + .then_with(|| a.competitor.cmp(&b.competitor)) + }); + order.into_iter().map(|p| p.competitor.clone()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass, SourceTime}; + + fn heat() -> HeatId { + HeatId("q-1".into()) + } + + fn scheduled(id: &str, lineup: &[&str]) -> Event { + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(), + } + } + + fn changed(id: &str, transition: HeatTransition) -> Event { + Event::HeatStateChanged { + heat: HeatId(id.into()), + transition, + } + } + + fn pass(competitor: &str, at: i64, seq: u64) -> Event { + Event::Pass(Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef(competitor.into()), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + }) + } + + #[test] + fn empty_log_is_idle_default() { + assert_eq!(live_state(&[]), LiveRaceState::default()); + let s = live_state(&[]); + assert_eq!(s.current_heat, None); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert!(s.active_pilots.is_empty()); + } + + #[test] + fn scheduled_heat_reports_lineup_and_scheduled_phase() { + let events = vec![scheduled("q-1", &["A", "B", "C"])]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(heat())); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert_eq!( + s.active_pilots, + vec![ + CompetitorRef("A".into()), + CompetitorRef("B".into()), + CompetitorRef("C".into()) + ] + ); + // No laps yet: progress entries exist but are zeroed. + assert_eq!(s.progress.len(), 3); + assert!(s.progress.iter().all(|p| p.laps_completed == 0)); + } + + #[test] + fn phase_tracks_the_heat_loop_through_scored() { + // Scheduled → Staged → Armed → Running → Finished(Landed) → Scored. + let steps = [ + (HeatTransition::Staged, HeatPhase::Staged), + (HeatTransition::Armed, HeatPhase::Armed), + (HeatTransition::Running, HeatPhase::Running), + (HeatTransition::Finished, HeatPhase::Landed), + (HeatTransition::Scored, HeatPhase::Scored), + ]; + let mut events = vec![scheduled("q-1", &["A", "B"])]; + assert_eq!(live_state(&events).phase, HeatPhase::Scheduled); + for (transition, expected) in steps { + events.push(changed("q-1", transition)); + assert_eq!(live_state(&events).phase, expected, "after {transition:?}"); + } + } + + #[test] + fn live_progress_counts_laps_and_last_lap_per_pilot() { + // A: 3 passes ⇒ 2 laps (last lap 2.5s). B: 2 passes ⇒ 1 lap (3.0s). + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + pass("A", 1_000_000, 1), + pass("B", 1_500_000, 1), + pass("A", 4_000_000, 2), + pass("B", 4_500_000, 2), + pass("A", 6_500_000, 3), + ]; + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Running); + + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!(a.laps_completed, 2); + assert_eq!(a.last_lap_micros, Some(2_500_000)); + + let b = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("B".into())) + .unwrap(); + assert_eq!(b.laps_completed, 1); + assert_eq!(b.last_lap_micros, Some(3_000_000)); + + // Running order: A (2 laps) leads B (1 lap). + assert_eq!( + s.running_order, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())] + ); + } + + #[test] + fn running_order_breaks_lap_ties_by_last_lap_time() { + // Both completed 1 lap; B's lap (2.0s) is faster than A's (3.0s) ⇒ B leads. + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Running), + pass("A", 1_000_000, 1), + pass("B", 1_000_000, 1), + pass("A", 4_000_000, 2), // A lap = 3.0s + pass("B", 3_000_000, 2), // B lap = 2.0s + ]; + let s = live_state(&events); + assert_eq!( + s.running_order, + vec![CompetitorRef("B".into()), CompetitorRef("A".into())] + ); + } + + #[test] + fn current_heat_follows_the_most_recently_active_heat() { + // q-1 runs and scores; q-2 is then scheduled and becomes current. + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + changed("q-1", HeatTransition::Finished), + changed("q-1", HeatTransition::Scored), + scheduled("q-2", &["C", "D"]), + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-2".into()))); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert_eq!( + s.active_pilots, + vec![CompetitorRef("C".into()), CompetitorRef("D".into())] + ); + } + + #[test] + fn on_deck_is_the_next_still_scheduled_heat() { + // q-1 is running (current); q-2 and q-3 are scheduled and waiting. + let events = vec![ + scheduled("q-1", &["A", "B"]), + scheduled("q-2", &["C", "D"]), + scheduled("q-3", &["E", "F"]), + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(s.phase, HeatPhase::Running); + // q-2 is the next still-scheduled heat behind the current one. + assert_eq!(s.on_deck, Some(HeatId("q-2".into()))); + } + + #[test] + fn fold_is_deterministic() { + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Running), + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), + ]; + assert_eq!(live_state(&events), live_state(&events)); + } +} diff --git a/crates/server/src/snapshot.rs b/crates/server/src/snapshot.rs index 653d937..0354114 100644 --- a/crates/server/src/snapshot.rs +++ b/crates/server/src/snapshot.rs @@ -29,32 +29,15 @@ use gridfpv_engine::event::EventOutcome; use gridfpv_engine::format::RankEntry; use gridfpv_engine::scoring::HeatResult; -use gridfpv_events::HeatId; use gridfpv_projection::LapList; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::stream::Cursor; -/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive -/// core every overlay and spectator watches: the current heat and its loop state, the -/// active pilots' live lap/split progress, the running order, and the on-deck heat. -/// -/// **#41 placeholder.** This is intentionally minimal-but-real: it carries the current -/// heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the -/// change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot -/// live progress, the running order, and the on-deck heat. Keeping the type real (not a -/// unit stub) means #41 extends fields additively (§7) without reshaping the envelope. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export, export_to = "bindings/")] -pub struct LiveRaceState { - /// The heat currently on the timer, if any (`None` between heats). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub current_heat: Option, - /// The current heat's loop phase (protocol.html §1, race-engine.html §2). - pub phase: HeatPhase, -} +// The live race-state projection (#41) lives in [`crate::live_state`]; it is re-exported +// here so `ProjectionBody::LiveRaceState` keeps naming a snapshot-module type. +pub use crate::live_state::{LiveRaceState, PilotProgress}; /// The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged → /// Armed → Running → Landed → Scored`). @@ -163,8 +146,9 @@ mod tests { fn sample_live() -> ProjectionBody { ProjectionBody::LiveRaceState(LiveRaceState { - current_heat: Some(HeatId("q-1".into())), + current_heat: Some(gridfpv_events::HeatId("q-1".into())), phase: HeatPhase::Running, + ..Default::default() }) } diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs index 66b4086..684fb07 100644 --- a/crates/server/src/stream.rs +++ b/crates/server/src/stream.rs @@ -120,6 +120,7 @@ mod tests { change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState { current_heat: Some(HeatId("q-1".into())), phase: HeatPhase::Running, + ..Default::default() })), }; let json = serde_json::to_string(&env).unwrap(); From 1a25297294efe11d54ed7e4b2a7e25f6dfb9898f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 03:59:42 +0000 Subject: [PATCH 005/362] Wire generated bindings into @gridfpv/types; implement protocol-client (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the @gridfpv/types placeholders with a real barrel over the ts-rs bindings, and implement @gridfpv/protocol-client (snapshot + WS subscribe + cursor resume), typed end to end against the generated wire types. Types seam: - packages/types/src/generated.ts now re-exports every bindings/*.ts module (export type * from '@bindings/'), resolved via a single @bindings/* path alias moved to tsconfig.base.json so every workspace resolves the chain identically. ts-rs emits no index.ts, so this barrel is one line per type. - @gridfpv/types is a pure type seam (all `export type`): it type-checks with --noEmit instead of emitting a dist/, which also lets the @bindings/* re-exports live outside the package tree without tripping rootDir. A Rust contract change now surfaces as a TS compile error in the frontend. protocol-client: - connect({ baseUrl, scope, token? }) -> ProtocolClient. Base-URL only (cannot tell LAN from Cloud). GET the scoped snapshot (carrying its cursor), then open a WebSocket and subscribe from that cursor. - Applies ChangeEnvelopes in strict sequence order, idempotent and keyed by sequence; a gap re-snapshots and re-subscribes from the fresh cursor; a socket drop reconnects and resumes by last-applied cursor, with a StaleCursor -> re-snapshot fallback (protocol.html §2/§3). - Framework-agnostic state API: getState() + onState(cb). Cursor (u64/bigint) wire coercion handled at the seam. fetch/WebSocket/timers are injectable. Tests: vitest unit tests drive the client against a mock fetch (snapshot) and a scripted mock WebSocket — asserting snapshot+subscribe, in-order convergence, idempotent re-delivery, gap re-snapshot, stale-cursor re-snapshot, and reconnect-resume. Updated the Leaderboard/App demo wiring off the removed placeholder type onto real generated types. build/check/test/lint all green. Part of #49. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/README.md | 23 +- frontend/apps/rd-console/src/App.svelte | 36 +- frontend/package-lock.json | 955 +++++++++++++++++- frontend/package.json | 1 + .../components/src/Leaderboard.svelte | 11 +- .../packages/protocol-client/package.json | 6 +- .../protocol-client/src/client.test.ts | 322 ++++++ .../packages/protocol-client/src/client.ts | 458 +++++++++ .../packages/protocol-client/src/index.ts | 70 +- .../packages/protocol-client/tsconfig.json | 5 +- .../protocol-client/tsconfig.test.json | 10 + .../packages/protocol-client/vitest.config.ts | 19 + frontend/packages/types/package.json | 2 +- frontend/packages/types/src/generated.ts | 91 +- frontend/packages/types/src/index.ts | 8 +- frontend/packages/types/tsconfig.json | 18 +- frontend/tsconfig.base.json | 11 + 17 files changed, 1888 insertions(+), 158 deletions(-) create mode 100644 frontend/packages/protocol-client/src/client.test.ts create mode 100644 frontend/packages/protocol-client/src/client.ts create mode 100644 frontend/packages/protocol-client/tsconfig.test.json create mode 100644 frontend/packages/protocol-client/vitest.config.ts diff --git a/frontend/README.md b/frontend/README.md index 47bc13b..a833b22 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -28,7 +28,7 @@ frontend/ ├── eslint.config.js # flat config, TS + Svelte ├── packages/ │ ├── types/ # @gridfpv/types — re-exports the generated bindings/*.ts -│ ├── protocol-client/ # @gridfpv/protocol-client — thin transport+subscribe layer (STUB, filled by #49) +│ ├── protocol-client/ # @gridfpv/protocol-client — thin transport+subscribe layer (snapshot + WS, #49) │ └── components/ # @gridfpv/components — shared Svelte 5 component library (svelte-package) └── apps/ └── rd-console/ # @gridfpv/rd-console — the RD console surface (minimal shell, filled by #51+) @@ -48,23 +48,23 @@ into the repo-root `bindings/` directory (one file per type, per `docs/clients.h generated bindings so every app and package imports protocol types from one place: ```ts -import type { RaceSnapshot, PilotId } from '@gridfpv/types'; +import type { Snapshot, ChangeEnvelope, Command, LapList } from '@gridfpv/types'; ``` How regenerated bindings flow in: -1. The Rust side regenerates `bindings/*.ts` (e.g. `cargo test` with ts-rs, or `xtask`). -2. `packages/types/src/index.ts` re-exports from the generated barrel. While `bindings/` has - no barrel of its own, `packages/types/src/generated.ts` is the adapter that points at it - (via the `tsconfig` path alias `@bindings/*` → `../../../bindings/*`). +1. The Rust side regenerates `bindings/*.ts` (e.g. `cargo xtask gen`). +2. `packages/types/src/index.ts` re-exports from `packages/types/src/generated.ts`, the + barrel. ts-rs emits one file per type and no `index.ts` of its own, so `generated.ts` + re-exports each `bindings/*.ts` module (`export type * from '@bindings/'`), resolved + through the `@bindings/*` tsconfig path alias (`@bindings/* → ../bindings/*`, defined once + in `tsconfig.base.json`). Adding a new type is one new `export type *` line; nothing else. 3. Nothing else changes: apps already import from `@gridfpv/types`, so a contract change in Rust surfaces as a TypeScript compile error in the frontend rather than silent drift. -**Standalone-build note.** When `bindings/` is absent (e.g. a frontend-only checkout, or CI -that hasn't run the Rust generation step), `@gridfpv/types` falls back to a small set of -placeholder types in `src/generated.ts` so the monorepo still builds and type-checks. Once -real bindings exist, that fallback is replaced by the re-export — see the comments in -`packages/types/src/generated.ts`. +`@gridfpv/types` is a pure type seam — every export is `export type`, so it emits no JS and +consumers resolve `@gridfpv/types` straight to its TypeScript source. It type-checks with +`tsc --noEmit` rather than producing a `dist/`. ## Commands @@ -72,6 +72,7 @@ real bindings exist, that fallback is replaced by the re-export — see the comm npm install # from frontend/ — installs all workspaces npm run build # build components + rd-console (and any other workspace) npm run check # svelte-check / tsc across workspaces +npm run test # vitest across workspaces (protocol-client unit tests) npm run lint # eslint + prettier --check npm run format # prettier --write npm run dev:rd-console # vite dev server for the RD console diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 411c6fc..a233fc8 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -7,16 +7,36 @@ // results) is built out in #51+. import { Leaderboard, RaceClock } from '@gridfpv/components'; import { connect } from '@gridfpv/protocol-client'; - import type { RaceSnapshot } from '@gridfpv/types'; + import type { HeatResult, Scope } from '@gridfpv/types'; - // Placeholder data until the protocol client (#49) streams real snapshots. - const demo: RaceSnapshot = { - raceId: 'demo-heat-1', - pilots: ['ALICE', 'BOB', 'CARMEN'] + // Placeholder data until the live projection (#51+) streams real results. + const demo: HeatResult = { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_250_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 42_100_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, + position: 3, + laps: 2, + metric: { BestLapMicros: null } + } + ] }; - // The client is a stub today (#49 implements it); this just shows the wiring. - const client = connect({ baseUrl: 'http://localhost:8080' }); + // The real protocol client (#49): snapshot + WS subscribe, scoped to this heat. + // The console reads `client.getState()` / `client.onState(...)` in #51+. + const scope: Scope = { Heat: { heat: 'demo-heat-1' } }; + const client = connect({ baseUrl: 'http://localhost:8080', scope });
@@ -30,7 +50,7 @@

Leaderboard

- +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee42ba0..13d06dd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1638,6 +1638,119 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1711,6 +1824,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1739,6 +1862,16 @@ "concat-map": "0.0.1" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1749,6 +1882,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1766,6 +1916,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1872,6 +2032,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1896,6 +2066,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2184,6 +2361,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2194,6 +2381,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2537,6 +2734,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2686,6 +2890,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2995,6 +3216,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3005,6 +3233,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3175,6 +3417,20 @@ "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3192,6 +3448,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3273,64 +3559,660 @@ "dev": true, "license": "MIT" }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "node-which": "bin/node-which" + "vite": "bin/vite.js" }, "engines": { - "node": ">= 8" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dev": true, "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 6" + "node": ">=12" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "packages/components": { - "name": "@gridfpv/components", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, + "packages/components": { + "name": "@gridfpv/components", "version": "0.0.0", "license": "AGPL-3.0-or-later", "dependencies": { @@ -3491,7 +4373,8 @@ "@gridfpv/types": "*" }, "devDependencies": { - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.8" } }, "packages/types": { diff --git a/frontend/package.json b/frontend/package.json index f836c27..d2f8f98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "npm run build --workspaces --if-present", "check": "npm run check --workspaces --if-present", + "test": "npm run test --workspaces --if-present", "lint": "eslint . && prettier --check .", "format": "prettier --write .", "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console" diff --git a/frontend/packages/components/src/Leaderboard.svelte b/frontend/packages/components/src/Leaderboard.svelte index cf39cde..59bbfc5 100644 --- a/frontend/packages/components/src/Leaderboard.svelte +++ b/frontend/packages/components/src/Leaderboard.svelte @@ -1,16 +1,19 @@
    - {#each snapshot.pilots as pilotId, i (pilotId)} -
  1. {i + 1}{pilotId}
  2. + {#each result.places as place (place.competitor.competitor)} +
  3. + {place.position} + {place.competitor.competitor} +
  4. {/each}
diff --git a/frontend/packages/protocol-client/package.json b/frontend/packages/protocol-client/package.json index 97f2205..2102a81 100644 --- a/frontend/packages/protocol-client/package.json +++ b/frontend/packages/protocol-client/package.json @@ -14,12 +14,14 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json", + "test": "vitest run" }, "dependencies": { "@gridfpv/types": "*" }, "devDependencies": { - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.8" } } diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts new file mode 100644 index 0000000..bb897af --- /dev/null +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; +import type { + ChangeEnvelope, + HeatPhase, + ProjectionBody, + ProtocolError, + Scope +} from '@gridfpv/types'; +import { connect } from './client.js'; +import type { FetchLike, WebSocketLike } from './client.js'; + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +const SCOPE: Scope = { Heat: { heat: 'heat-1' } }; + +const liveState = (phase: HeatPhase): ProjectionBody => ({ + LiveRaceState: { current_heat: 'heat-1', phase } +}); + +// A fetch mock that serves the given snapshots in order (sticking on the last) +// and records every requested URL. Each response's `json()` yields a Snapshot +// whose cursor is rendered as a JSON number — exactly how serde renders the u64 +// `Cursor` on the wire — which the client coerces back to a bigint. +function mockFetch(snapshots: Array<{ cursor: bigint; body: ProjectionBody }>): { + fetch: FetchLike; + calls: string[]; +} { + const calls: string[] = []; + let i = 0; + const fetch: FetchLike = async (input) => { + calls.push(String(input)); + const snap = snapshots[Math.min(i, snapshots.length - 1)]; + i += 1; + return { + ok: true, + status: 200, + json: async (): Promise => ({ cursor: Number(snap.cursor), body: snap.body }) + } as unknown as Response; + }; + return { fetch, calls }; +} + +// A scriptable mock WebSocket. Tests drive it: open it, then push frames. +class MockWebSocket implements WebSocketLike { + onopen: ((this: WebSocketLike, ev: unknown) => unknown) | null = null; + onclose: ((this: WebSocketLike, ev: unknown) => unknown) | null = null; + onerror: ((this: WebSocketLike, ev: unknown) => unknown) | null = null; + onmessage: ((this: WebSocketLike, ev: { data: unknown }) => unknown) | null = null; + + readonly url: string; + readonly sent: string[] = []; + closed = false; + + constructor(url: string) { + this.url = url; + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + } + + // ── Test drivers ── + open(): void { + this.onopen?.call(this, {}); + } + emit(frame: unknown): void { + // Mirror the wire: serde renders the u64 Cursor as a JSON number, so emit + // bigints as numbers (JSON.stringify cannot serialize a bigint directly). + const data = JSON.stringify(frame, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); + this.onmessage?.call(this, { data }); + } + drop(): void { + this.onclose?.call(this, {}); + } +} + +// Collect every MockWebSocket the client opens, so tests can drive the latest one. +function mockWsFactory(): { factory: (url: string) => WebSocketLike; sockets: MockWebSocket[] } { + const sockets: MockWebSocket[] = []; + const factory = (url: string): WebSocketLike => { + const ws = new MockWebSocket(url); + sockets.push(ws); + return ws; + }; + return { factory, sockets }; +} + +// A controllable timer: tests fire the queued callback on demand (deterministic +// reconnect, no real waiting). +function manualTimer(): { + setTimer: (cb: () => void, ms: number) => unknown; + clearTimer: (h: unknown) => void; + fire: () => void; + pending: () => boolean; +} { + let queued: (() => void) | null = null; + return { + setTimer: (cb) => { + queued = cb; + return 1; + }, + clearTimer: () => { + queued = null; + }, + fire: () => { + const cb = queued; + queued = null; + cb?.(); + }, + pending: () => queued !== null + }; +} + +const envelope = (sequence: bigint, phase: HeatPhase): ChangeEnvelope => ({ + sequence, + projection: 'LiveRaceState', + change: { FreshValue: liveState(phase) } +}); + +// Let queued microtasks (the async snapshot fetch) settle. +const flush = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const phaseOf = (body: ProjectionBody | undefined): HeatPhase | undefined => + body && 'LiveRaceState' in body ? body.LiveRaceState.phase : undefined; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProtocolClient', () => { + it('fetches the scoped snapshot, then subscribes from its cursor', async () => { + const { fetch, calls } = mockFetch([{ cursor: 10n, body: liveState('Staged') }]); + const { factory, sockets } = mockWsFactory(); + + const client = connect({ + baseUrl: 'http://director.local:8080', + scope: SCOPE, + fetch, + webSocketFactory: factory + }); + await flush(); + + // Snapshot was fetched and applied. + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('/snapshot?scope='); + expect(client.getState().cursor).toBe(10n); + expect(phaseOf(client.getState().body)).toBe('Staged'); + + // A socket was opened; on open it sends a SubscribeRequest resuming from 10. + expect(sockets).toHaveLength(1); + sockets[0].open(); + expect(sockets[0].sent).toHaveLength(1); + const req = JSON.parse(sockets[0].sent[0]); + expect(req.scope).toEqual(SCOPE); + expect(req.from).toBe(10); // cursor serialized as a JSON number (serde u64 default) + expect(client.getState().status).toBe('live'); + + client.close(); + }); + + it('applies an ordered change stream in sequence and stays converged', async () => { + const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + sockets[0].emit(envelope(1n, 'Staged')); + sockets[0].emit(envelope(2n, 'Armed')); + sockets[0].emit(envelope(3n, 'Running')); + + expect(client.getState().cursor).toBe(3n); + expect(phaseOf(client.getState().body)).toBe('Running'); + + client.close(); + }); + + it('is idempotent: re-delivered envelopes at/below the cursor are no-ops', async () => { + const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + sockets[0].emit(envelope(1n, 'Staged')); + sockets[0].emit(envelope(2n, 'Armed')); + // Re-deliver 1 and 2 (at-least-once): they must not regress the state. + sockets[0].emit(envelope(1n, 'Scheduled')); + sockets[0].emit(envelope(2n, 'Scheduled')); + + expect(client.getState().cursor).toBe(2n); + expect(phaseOf(client.getState().body)).toBe('Armed'); + + client.close(); + }); + + it('re-snapshots on a sequence gap, then resumes from the fresh cursor', async () => { + const { fetch, calls } = mockFetch([ + { cursor: 0n, body: liveState('Scheduled') }, // initial snapshot + { cursor: 5n, body: liveState('Running') } // re-snapshot after the gap + ]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + sockets[0].emit(envelope(1n, 'Staged')); + // Gap: jump to 4 (missed 2 and 3). The client must re-snapshot. + sockets[0].emit(envelope(4n, 'Armed')); + await flush(); + + // A second snapshot fetch happened and the old socket was torn down. + expect(calls).toHaveLength(2); + expect(sockets[0].closed).toBe(true); + expect(client.getState().cursor).toBe(5n); + expect(phaseOf(client.getState().body)).toBe('Running'); + + // A fresh socket re-subscribes from the new cursor (5). + expect(sockets).toHaveLength(2); + sockets[1].open(); + const req = JSON.parse(sockets[1].sent[0]); + expect(req.from).toBe(5); + + // The stream continues converged from 6. + sockets[1].emit(envelope(6n, 'Landed')); + expect(client.getState().cursor).toBe(6n); + expect(phaseOf(client.getState().body)).toBe('Landed'); + + client.close(); + }); + + it('re-snapshots when the server reports a stale cursor', async () => { + const { fetch, calls } = mockFetch([ + { cursor: 100n, body: liveState('Staged') }, + { cursor: 200n, body: liveState('Running') } + ]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + const staleErr: ProtocolError = { code: 'StaleCursor', message: 'cursor too old to replay' }; + sockets[0].emit({ error: staleErr }); + await flush(); + + expect(calls).toHaveLength(2); + expect(client.getState().cursor).toBe(200n); + expect(sockets).toHaveLength(2); + + client.close(); + }); + + it('reconnects on socket drop and resumes from the last-applied cursor', async () => { + const { fetch, calls } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + const timer = manualTimer(); + const client = connect({ + baseUrl: 'http://d', + scope: SCOPE, + fetch, + webSocketFactory: factory, + setTimer: timer.setTimer, + clearTimer: timer.clearTimer, + reconnectDelayMs: 5 + }); + await flush(); + sockets[0].open(); + sockets[0].emit(envelope(1n, 'Staged')); + sockets[0].emit(envelope(2n, 'Armed')); + + // Socket drops. + sockets[0].drop(); + expect(client.getState().status).toBe('reconnecting'); + expect(timer.pending()).toBe(true); + + // Reconnect fires: it resumes by re-subscribing (no re-snapshot needed). + timer.fire(); + await flush(); + expect(calls).toHaveLength(1); // no extra snapshot — resumed by cursor + expect(sockets).toHaveLength(2); + + sockets[1].open(); + const req = JSON.parse(sockets[1].sent[0]); + expect(req.from).toBe(2); + expect(client.getState().status).toBe('live'); + + // Stream continues converged. + sockets[1].emit(envelope(3n, 'Running')); + expect(client.getState().cursor).toBe(3n); + + client.close(); + }); + + it('notifies onState listeners and stops after close', async () => { + const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + + const seen: (HeatPhase | undefined)[] = []; + const unsub = client.onState((s) => seen.push(phaseOf(s.body))); + await flush(); + sockets[0].open(); + sockets[0].emit(envelope(1n, 'Staged')); + + expect(seen).toContain('Scheduled'); + expect(seen).toContain('Staged'); + + unsub(); + const before = seen.length; + sockets[0].emit(envelope(2n, 'Armed')); + expect(seen.length).toBe(before); // unsubscribed: no more notifications + + client.close(); + expect(client.getState().status).toBe('closed'); + }); +}); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts new file mode 100644 index 0000000..01598aa --- /dev/null +++ b/frontend/packages/protocol-client/src/client.ts @@ -0,0 +1,458 @@ +/** + * The protocol client implementation. + * + * Lifecycle (protocol.html §2–§3): + * + * 1. `connect()` → GET the scoped snapshot. The snapshot carries the projection + * `body` plus the `cursor` the stream resumes from. + * 2. Open a WebSocket and send a `SubscribeRequest { scope, from: cursor }`. + * 3. Apply each incoming `ChangeEnvelope` in strictly increasing `sequence` + * order. Application is idempotent and keyed by `sequence`: an envelope at or + * below the last-applied cursor is a no-op (at-least-once, deduplicated). + * 4. A *gap* (an envelope whose sequence skips past `lastApplied + 1`) means we + * missed envelopes the stream cannot replay → re-snapshot and re-subscribe + * from the fresh cursor. + * 5. On socket drop, reconnect and resume from the last-applied cursor; if the + * server reports the cursor is too old (`StaleCursor`) — or resume otherwise + * fails — fall back to a re-snapshot. + */ + +import type { + ChangeEnvelope, + Cursor, + ProjectionBody, + ProtocolError, + Scope, + Snapshot, + SubscribeRequest +} from '@gridfpv/types'; + +/** Minimal `fetch` surface this client needs (injectable for tests / Node). */ +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +/** + * Minimal `WebSocket` surface this client needs — a structural subset of the DOM + * `WebSocket` so tests can inject a mock and Node can supply a polyfill. + */ +export interface WebSocketLike { + send(data: string): void; + close(code?: number, reason?: string): void; + onopen: ((this: WebSocketLike, ev: unknown) => unknown) | null; + onclose: ((this: WebSocketLike, ev: unknown) => unknown) | null; + onerror: ((this: WebSocketLike, ev: unknown) => unknown) | null; + onmessage: ((this: WebSocketLike, ev: { data: unknown }) => unknown) | null; +} + +/** Factory that opens a {@link WebSocketLike} for a `ws(s)://…` URL. */ +export type WebSocketFactory = (url: string) => WebSocketLike; + +/** Where the connection is in its snapshot/stream lifecycle. */ +export type ConnectionStatus = + | 'connecting' + | 'snapshotting' + | 'subscribing' + | 'live' + | 'reconnecting' + | 'closed'; + +/** The current typed projection state the client exposes to consumers. */ +export interface ProtocolState { + /** The materialized projection body, or `undefined` before the first snapshot. */ + readonly body: ProjectionBody | undefined; + /** The last-applied stream cursor (snapshot cursor, advanced by each envelope). */ + readonly cursor: Cursor | undefined; + /** Lifecycle status. */ + readonly status: ConnectionStatus; + /** The last protocol/transport error, if the connection is degraded. */ + readonly error: ProtocolError | undefined; +} + +/** A listener notified on every state change. Returns an unsubscribe function. */ +export type StateListener = (state: ProtocolState) => void; + +/** Options for {@link connect}. */ +export interface ConnectOptions { + /** + * Base URL of the Director (or Cloud) protocol server, e.g. + * `http://director.local:8080` or `https://cloud.gridfpv.example`. The client + * is configured with the base URL *only*, so it cannot tell LAN from Cloud. + */ + baseUrl: string; + /** The resource this connection is scoped to (protocol.html §4). */ + scope: Scope; + /** Optional bearer token (sent as `Authorization: Bearer …` and on the WS URL). */ + token?: string; + /** Inject a `fetch` (defaults to the global). Used by tests and Node. */ + fetch?: FetchLike; + /** Inject a WebSocket factory (defaults to the global `WebSocket`). */ + webSocketFactory?: WebSocketFactory; + /** + * Reconnect backoff in ms (delay before re-opening a dropped socket). + * Defaults to 1000ms. A timer of `0` reconnects on the next tick. + */ + reconnectDelayMs?: number; + /** Inject a timer (defaults to `setTimeout`). Used by tests. */ + setTimer?: (cb: () => void, ms: number) => unknown; + /** Inject a timer-clear (defaults to `clearTimeout`). Used by tests. */ + clearTimer?: (handle: unknown) => void; +} + +/** + * A live connection to the protocol server: snapshot + WS subscribe, exposing the + * current typed projection state via a framework-agnostic subscribe API. + */ +export interface ProtocolClient { + readonly baseUrl: string; + readonly scope: Scope; + /** A synchronous snapshot of the current state. */ + getState(): ProtocolState; + /** + * Subscribe to state changes. The listener is invoked immediately with the + * current state, then on every subsequent change. Returns an unsubscribe fn. + */ + onState(listener: StateListener): () => void; + /** Close the connection and tear down the WebSocket. Idempotent. */ + close(): void; +} + +/** Error frame the server may send on the stream (protocol.html §9.8). */ +interface ErrorFrame { + error: ProtocolError; +} + +// ── Cursor (bigint) wire handling ────────────────────────────────────────────── +// +// `Cursor` is a u64 rendered as a TS `bigint` (bindings/Cursor.ts). On the wire it +// arrives as a JSON number or string depending on the server's serializer; `bigint` +// values, conversely, are not serializable by `JSON.stringify`. These two helpers +// bracket that mismatch so cursors stay precise (a u64 can exceed JS's safe-integer +// range) and the rest of the client works in `bigint`. + +/** Coerce a wire cursor (number | string | bigint) to a `bigint`. */ +function toCursor(v: unknown): Cursor { + if (typeof v === 'bigint') return v; + if (typeof v === 'number') return BigInt(Math.trunc(v)); + if (typeof v === 'string' && v.length > 0) return BigInt(v); + throw new Error(`invalid cursor: ${String(v)}`); +} + +/** + * `JSON.stringify` with bigints rendered as JSON numbers (serde's u64 default), + * since `JSON.stringify` cannot serialize a bigint directly. Cursors past 2^53 + * lose precision in this number form; if a deployment ever needs full-u64 cursors + * on the wire the server would serialize them as strings and this would follow. + */ +function stringifyWire(value: unknown): string { + return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); +} + +/** Normalize a parsed snapshot so its cursor is a `bigint`. */ +function normalizeSnapshot(data: Snapshot): Snapshot { + return { ...data, cursor: toCursor((data as { cursor: unknown }).cursor) }; +} + +/** Normalize a parsed envelope so its sequence is a `bigint`. */ +function normalizeEnvelope(env: ChangeEnvelope): ChangeEnvelope { + return { ...env, sequence: toCursor((env as { sequence: unknown }).sequence) }; +} + +const isProtocolError = (v: unknown): v is ProtocolError => + typeof v === 'object' && + v !== null && + 'code' in v && + 'message' in v && + typeof (v as ProtocolError).message === 'string'; + +const isChangeEnvelope = (v: unknown): v is ChangeEnvelope => + typeof v === 'object' && v !== null && 'sequence' in v && 'projection' in v && 'change' in v; + +const isErrorFrame = (v: unknown): v is ErrorFrame => + typeof v === 'object' && v !== null && 'error' in v && isProtocolError((v as ErrorFrame).error); + +/** Map an http(s) base URL to its ws(s) equivalent. */ +function toWebSocketBase(baseUrl: string): string { + if (baseUrl.startsWith('https://')) return 'wss://' + baseUrl.slice('https://'.length); + if (baseUrl.startsWith('http://')) return 'ws://' + baseUrl.slice('http://'.length); + return baseUrl; +} + +const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s); + +/** + * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. + * + * Returns immediately with a {@link ProtocolClient}; the snapshot fetch and WS + * subscribe proceed asynchronously and surface through the state/`onState` API. + */ +export function connect(options: ConnectOptions): ProtocolClient { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const wsFactory: WebSocketFactory = + options.webSocketFactory ?? + ((url) => new globalThis.WebSocket(url) as unknown as WebSocketLike); + const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms)); + const clearTimer = + options.clearTimer ?? ((h) => clearTimeout(h as ReturnType)); + const reconnectDelayMs = options.reconnectDelayMs ?? 1000; + + const baseUrl = trimSlash(options.baseUrl); + const wsBase = trimSlash(toWebSocketBase(options.baseUrl)); + const scope = options.scope; + const token = options.token; + const scopeParam = encodeURIComponent(JSON.stringify(scope)); + + // ── Mutable connection state ─────────────────────────────────────────────── + let body: ProjectionBody | undefined; + let cursor: Cursor | undefined; + let status: ConnectionStatus = 'connecting'; + let lastError: ProtocolError | undefined; + + let ws: WebSocketLike | null = null; + let closed = false; + let reconnectHandle: unknown = null; + /** Bumps every (re)connect attempt so stale callbacks from an old socket no-op. */ + let generation = 0; + + const listeners = new Set(); + + const snapshot = (): ProtocolState => ({ body, cursor, status, error: lastError }); + + function emit(): void { + const s = snapshot(); + for (const l of listeners) l(s); + } + + function setStatus(next: ConnectionStatus): void { + if (status !== next) { + status = next; + emit(); + } + } + + function fail(err: ProtocolError): void { + lastError = err; + emit(); + } + + // ── Snapshot (protocol.html §2) ──────────────────────────────────────────── + async function fetchSnapshot(gen: number): Promise { + setStatus('snapshotting'); + const headers: Record = { Accept: 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + let resp: Response; + try { + resp = await fetchImpl(`${baseUrl}/snapshot?scope=${scopeParam}`, { headers }); + } catch (e) { + if (gen !== generation || closed) return false; + fail({ code: 'Internal', message: `snapshot fetch failed: ${String(e)}` }); + return false; + } + if (gen !== generation || closed) return false; + if (!resp.ok) { + let err: ProtocolError = { code: 'Internal', message: `snapshot HTTP ${resp.status}` }; + try { + const data: unknown = await resp.json(); + if (isProtocolError(data)) err = data; + } catch { + /* keep the HTTP-status error */ + } + fail(err); + return false; + } + const data = normalizeSnapshot((await resp.json()) as Snapshot); + if (gen !== generation || closed) return false; + body = data.body; + cursor = data.cursor; + lastError = undefined; + emit(); + return true; + } + + // ── Apply one ordered change envelope (protocol.html §3) ──────────────────── + // + // Returns 'applied', 'duplicate' (already seen — idempotent no-op), or 'gap' + // (missed envelopes → caller must re-snapshot). + function applyEnvelope(env: ChangeEnvelope): 'applied' | 'duplicate' | 'gap' { + const seq = env.sequence; + if (cursor !== undefined) { + // Idempotent, keyed by sequence: anything at or below the cursor is a no-op. + if (seq <= cursor) return 'duplicate'; + // The stream is contiguous: the next envelope must be exactly cursor + 1. + if (seq !== cursor + 1n) return 'gap'; + } + const change = env.change; + if ('FreshValue' in change) { + body = change.FreshValue; + } else { + // Delta. The per-projection delta encodings are deferred (#43): the wire + // type carries an opaque payload today. We advance the cursor so ordering + // and resume stay correct; once #43 pins the typed deltas, fold them into + // `body` here per ProjectionKind. Until then a delta cannot mutate `body`, + // and a re-snapshot (always correct, §3) reconciles any drift. + void change.Delta; + } + cursor = seq; + return 'applied'; + } + + // ── WebSocket subscribe + stream (protocol.html §3) ───────────────────────── + function openSocket(gen: number): void { + if (gen !== generation || closed) return; + setStatus('subscribing'); + const url = token ? `${wsBase}/stream?token=${encodeURIComponent(token)}` : `${wsBase}/stream`; + let socket: WebSocketLike; + try { + socket = wsFactory(url); + } catch (e) { + scheduleReconnect(gen, { code: 'Internal', message: `WS open failed: ${String(e)}` }); + return; + } + ws = socket; + + socket.onopen = () => { + if (gen !== generation || closed) return; + const req: SubscribeRequest = { scope, from: cursor }; + socket.send(stringifyWire(req)); + setStatus('live'); + }; + + socket.onmessage = (ev) => { + if (gen !== generation || closed) return; + void handleMessage(gen, ev.data); + }; + + socket.onerror = () => { + /* surfaced via onclose; nothing actionable here */ + }; + + socket.onclose = () => { + if (gen !== generation || closed) return; + scheduleReconnect(gen, undefined); + }; + } + + async function handleMessage(gen: number, raw: unknown): Promise { + let parsed: unknown; + try { + parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + fail({ code: 'BadRequest', message: 'malformed stream frame' }); + return; + } + + if (isErrorFrame(parsed)) { + // A stale cursor means resume is impossible → re-snapshot from scratch (§3). + if (parsed.error.code === 'StaleCursor') { + await resnapshot(gen); + } else { + fail(parsed.error); + } + return; + } + + if (!isChangeEnvelope(parsed)) { + // Unknown frame (e.g. a ServerHello in a richer handshake) — ignore it so an + // additive protocol change doesn't break an older client (§7). + return; + } + + const result = applyEnvelope(normalizeEnvelope(parsed)); + if (result === 'gap') { + // Missed envelopes the stream can't replay → re-snapshot and re-subscribe. + await resnapshot(gen); + return; + } + if (result === 'applied') emit(); + } + + // Re-snapshot in place (gap or stale cursor), then re-subscribe from the fresh + // cursor. The existing socket is torn down so the new subscribe is unambiguous. + async function resnapshot(gen: number): Promise { + if (gen !== generation || closed) return; + teardownSocket(); + const ok = await fetchSnapshot(gen); + if (gen !== generation || closed) return; + if (ok) openSocket(gen); + else scheduleReconnect(gen, lastError); + } + + function scheduleReconnect(gen: number, err: ProtocolError | undefined): void { + if (gen !== generation || closed) return; + teardownSocket(); + if (err) lastError = err; + setStatus('reconnecting'); + reconnectHandle = setTimer(() => { + if (closed) return; + reconnect(); + }, reconnectDelayMs); + } + + // Reconnect (protocol.html §3): resume from the last-applied cursor by + // re-subscribing; if we never got a snapshot, take one first. A StaleCursor on + // resume drives a re-snapshot via the stream error path. + function reconnect(): void { + if (closed) return; + const gen = ++generation; + if (cursor === undefined) { + void (async () => { + const ok = await fetchSnapshot(gen); + if (gen !== generation || closed) return; + if (ok) openSocket(gen); + else scheduleReconnect(gen, lastError); + })(); + } else { + openSocket(gen); + } + } + + function teardownSocket(): void { + if (reconnectHandle !== null) { + clearTimer(reconnectHandle); + reconnectHandle = null; + } + if (ws) { + const dead = ws; + ws = null; + dead.onopen = null; + dead.onclose = null; + dead.onerror = null; + dead.onmessage = null; + try { + dead.close(); + } catch { + /* ignore */ + } + } + } + + // ── Kick off the handshake ───────────────────────────────────────────────── + function start(): void { + const gen = ++generation; + void (async () => { + const ok = await fetchSnapshot(gen); + if (gen !== generation || closed) return; + if (ok) openSocket(gen); + else scheduleReconnect(gen, lastError); + })(); + } + + start(); + + return { + baseUrl, + scope, + getState: snapshot, + onState(listener: StateListener): () => void { + listeners.add(listener); + listener(snapshot()); + return () => listeners.delete(listener); + }, + close(): void { + if (closed) return; + closed = true; + generation++; + teardownSocket(); + setStatus('closed'); + listeners.clear(); + } + }; +} diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index b18b429..06e4161 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -1,55 +1,25 @@ /** - * @gridfpv/protocol-client — STUB. + * @gridfpv/protocol-client — the thin, framework-agnostic protocol layer + * described in docs/clients.html §3 and docs/protocol.html §2–§4. * - * The thin, framework-agnostic protocol layer described in docs/clients.html §3: - * connect to a base URL, fetch a projection snapshot, subscribe to the WebSocket - * change stream, and expose typed state. It is configured only with a base URL, - * so it cannot tell LAN from Cloud — the same client backs all three surfaces on - * both transports. + * It connects to a single base URL (so it cannot tell LAN from Cloud — the same + * client backs all three surfaces on both transports), performs the + * "snapshot first, then subscribe" handshake (protocol.html §2), applies the + * ordered change-envelope stream idempotently in sequence order (§3), and resumes + * by cursor — falling back to a re-snapshot — across gaps and reconnects. * - * This package currently only nails down the public surface so apps can wire - * against it. The real implementation (snapshot fetch, WS reconnect, typed - * subscriptions, auth headers) is issue #49. + * Everything is typed against the ts-rs–generated wire types re-exported from + * `@gridfpv/types`; this package hand-writes no wire shape of its own. */ -import type { RaceSnapshot } from '@gridfpv/types'; -/** Options for {@link connect}. Expanded by #49 (auth token, transports, etc.). */ -export interface ConnectOptions { - /** - * Base URL of the Director (or Cloud) protocol server, e.g. - * `http://director.local:8080` or `https://cloud.gridfpv.example`. - */ - baseUrl: string; -} - -/** - * A live connection to the protocol server. The shape here is a placeholder; #49 - * defines the real snapshot/subscribe/typed-state API. - */ -export interface ProtocolClient { - readonly baseUrl: string; - /** Fetch the current projection snapshot. Implemented by #49. */ - snapshot(): Promise; - /** Close the connection and tear down any WebSocket. Implemented by #49. */ - close(): void; -} - -/** - * Connect to a GridFPV protocol server. - * - * STUB: signature only. #49 implements snapshot + WS subscribe. - * - * @param options - connection options, or a bare base URL string for convenience. - */ -export function connect(options: ConnectOptions | string): ProtocolClient { - const baseUrl = typeof options === 'string' ? options : options.baseUrl; - return { - baseUrl, - snapshot(): Promise { - return Promise.reject(new Error('protocol-client: connect() is a stub — implemented by #49')); - }, - close(): void { - /* no-op until #49 */ - } - }; -} +export { connect } from './client.js'; +export type { + ConnectOptions, + ProtocolClient, + ProtocolState, + ConnectionStatus, + StateListener, + WebSocketLike, + WebSocketFactory, + FetchLike +} from './client.js'; diff --git a/frontend/packages/protocol-client/tsconfig.json b/frontend/packages/protocol-client/tsconfig.json index 5a24989..67c4b4e 100644 --- a/frontend/packages/protocol-client/tsconfig.json +++ b/frontend/packages/protocol-client/tsconfig.json @@ -4,5 +4,8 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src"] + "include": ["src"], + // Tests are type-checked and run by Vitest (vitest.config.ts), not shipped in + // the package build. + "exclude": ["src/**/*.test.ts"] } diff --git a/frontend/packages/protocol-client/tsconfig.test.json b/frontend/packages/protocol-client/tsconfig.test.json new file mode 100644 index 0000000..9f3c4d9 --- /dev/null +++ b/frontend/packages/protocol-client/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + // Type-checks the test sources (which the build tsconfig excludes from `dist`). + // Used by the `check` script; Vitest runs them at runtime. + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src"] +} diff --git a/frontend/packages/protocol-client/vitest.config.ts b/frontend/packages/protocol-client/vitest.config.ts new file mode 100644 index 0000000..4aa70b2 --- /dev/null +++ b/frontend/packages/protocol-client/vitest.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +// The client is typed against `@gridfpv/types`, whose source re-exports the +// ts-rs bindings through the `@bindings/*` alias (see tsconfig.base.json). Vitest +// uses Vite's resolver, not tsconfig `paths`, so mirror the alias here pointing at +// the repo-root `bindings/`. Everything imported at runtime is `export type`, so +// nothing of the bindings actually executes — this only satisfies resolution. +export default defineConfig({ + resolve: { + alias: { + '@bindings': fileURLToPath(new URL('../../../bindings', import.meta.url)) + } + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +}); diff --git a/frontend/packages/types/package.json b/frontend/packages/types/package.json index 9260ee4..fa699ac 100644 --- a/frontend/packages/types/package.json +++ b/frontend/packages/types/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 3719902..ebd4462 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -1,41 +1,70 @@ /** - * Adapter to the ts-rs–generated protocol bindings. + * Adapter to the ts-rs–generated protocol bindings — the single seam that knows + * where the generated wire types physically live. * * The wire types are generated from the Rust server crate into the repo-root * `bindings/` directory (one file per type), per docs/clients.html §3 and - * architecture.html §6. The frontend NEVER hand-writes a wire type — this file - * is the only seam that knows where the generated types physically live. + * architecture.html §6. The frontend NEVER hand-writes a wire type. * - * ── When real bindings exist ──────────────────────────────────────────────── - * `bindings/` is expected to expose a barrel (e.g. `bindings/index.ts`). Replace - * the placeholder block below with a single re-export, resolved through the - * `@bindings/*` tsconfig path alias: + * ── How this barrel works ─────────────────────────────────────────────────── + * ts-rs emits one file per type (`bindings/Snapshot.ts`, `bindings/Cursor.ts`, …) + * with extensionless cross-imports and no `index.ts` barrel of its own. This file + * is that barrel: it re-exports every generated type module, resolved through the + * `@bindings/*` tsconfig path alias (`@bindings/* → ../../../bindings/*`, set in + * packages/types/tsconfig.json — the one place that knows their physical location). * - * export * from '@bindings/index'; + * Because everything downstream imports from `@gridfpv/types`, a contract change in + * Rust (a renamed field, a removed variant) surfaces here — and in every consumer — + * as a TypeScript compile error rather than silent drift. * - * (or re-export the specific generated modules you need). Nothing else in the - * frontend changes, because everything imports from `@gridfpv/types`. + * ── Regenerating ──────────────────────────────────────────────────────────── + * The Rust side regenerates `bindings/*.ts` (`cargo xtask gen`). When a *new* type + * is added, add a matching `export type * from '@bindings/';` line below; + * when one is removed, drop its line. Nothing else in the frontend changes. * - * ── Standalone fallback (bindings/ absent) ────────────────────────────────── - * Until the Rust generation step has run — e.g. a frontend-only checkout or CI - * that builds the frontend in isolation — `bindings/` may not exist. To keep the - * monorepo buildable and type-checkable on its own, we define a minimal set of - * placeholder types here. These are intentionally thin and exist only so the - * scaffold compiles; they are replaced wholesale by the generated re-export. + * This list is kept in lockstep with `bindings/` (one line per `bindings/*.ts`). */ -/** Opaque identifier for a pilot. Generated type will supersede this. */ -export type PilotId = string; - -/** Opaque identifier for a race/heat. Generated type will supersede this. */ -export type RaceId = string; - -/** - * Placeholder snapshot shape. The real, ts-rs–generated projection snapshot - * type will replace this once `bindings/` is populated. - */ -export interface RaceSnapshot { - raceId: RaceId; - /** Pilots in finishing/standings order. */ - pilots: PilotId[]; -} +export type * from '@bindings/AdapterId'; +export type * from '@bindings/Change'; +export type * from '@bindings/ChangeEnvelope'; +export type * from '@bindings/ClassId'; +export type * from '@bindings/Command'; +export type * from '@bindings/CommandAck'; +export type * from '@bindings/CompetitorKey'; +export type * from '@bindings/CompetitorLaps'; +export type * from '@bindings/CompetitorRef'; +export type * from '@bindings/CompletedHeat'; +export type * from '@bindings/ContractVersion'; +export type * from '@bindings/Cursor'; +export type * from '@bindings/ErrorCode'; +export type * from '@bindings/Event'; +export type * from '@bindings/EventId'; +export type * from '@bindings/EventOutcome'; +export type * from '@bindings/GateIndex'; +export type * from '@bindings/HeatId'; +export type * from '@bindings/HeatPhase'; +export type * from '@bindings/HeatResult'; +export type * from '@bindings/HeatTransition'; +export type * from '@bindings/Hello'; +export type * from '@bindings/Lap'; +export type * from '@bindings/LapList'; +export type * from '@bindings/LiveRaceState'; +export type * from '@bindings/LogRef'; +export type * from '@bindings/Metric'; +export type * from '@bindings/Pass'; +export type * from '@bindings/Penalty'; +export type * from '@bindings/PilotId'; +export type * from '@bindings/Placement'; +export type * from '@bindings/ProjectionBody'; +export type * from '@bindings/ProjectionKind'; +export type * from '@bindings/ProtocolError'; +export type * from '@bindings/RankEntry'; +export type * from '@bindings/Scope'; +export type * from '@bindings/ServerHello'; +export type * from '@bindings/SessionId'; +export type * from '@bindings/SignalContext'; +export type * from '@bindings/Snapshot'; +export type * from '@bindings/SourceTime'; +export type * from '@bindings/SubscribeRequest'; +export type * from '@bindings/WinCondition'; diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts index e80efc8..a2760c7 100644 --- a/frontend/packages/types/src/index.ts +++ b/frontend/packages/types/src/index.ts @@ -3,10 +3,10 @@ * * Every app and package imports protocol/wire types from here: * - * import type { RaceSnapshot, PilotId } from '@gridfpv/types'; + * import type { Snapshot, ChangeEnvelope, Command, LapList } from '@gridfpv/types'; * - * The actual definitions come from the ts-rs–generated bindings (see - * ./generated.ts for how regenerated bindings flow in, and the standalone - * fallback used when `bindings/` is absent). + * The actual definitions come from the ts-rs–generated bindings; see + * ./generated.ts for how the barrel re-exports each `bindings/*.ts` and how + * regenerated bindings flow in. */ export * from './generated.js'; diff --git a/frontend/packages/types/tsconfig.json b/frontend/packages/types/tsconfig.json index 52d7761..9390d10 100644 --- a/frontend/packages/types/tsconfig.json +++ b/frontend/packages/types/tsconfig.json @@ -1,16 +1,14 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "baseUrl": ".", - "paths": { - // The generated ts-rs bindings live at the repo root. The `@bindings/*` - // alias is the one place that knows their physical location, so apps and - // packages only ever import from `@gridfpv/types`. When real bindings - // exist, src/generated.ts re-exports from here. - "@bindings/*": ["../../../bindings/*"] - } + // This package is a pure type seam: every `export` in src/ is `export type`, + // so there is no JS to emit and consumers resolve `@gridfpv/types` straight to + // `src/index.ts` (see package.json `exports`). We type-check only (`noEmit`) + // rather than emit a `dist/`, which also lets the `@bindings/*` re-exports + // (resolved via the shared base-config alias) live outside this package's tree + // without tripping `rootDir`. + "noEmit": true, + "declaration": false }, "include": ["src"] } diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json index 44e4ae6..5853529 100644 --- a/frontend/tsconfig.base.json +++ b/frontend/tsconfig.base.json @@ -1,5 +1,16 @@ { "compilerOptions": { + // The ts-rs–generated bindings live at the repo root (one file per type). The + // `@bindings/*` alias is the single place that knows their physical location. + // It lives in the shared base config (resolved relative to this file in + // frontend/) so any workspace that follows an import chain into + // `@gridfpv/types` → src/generated.ts resolves the re-exports identically. + // Only `@gridfpv/types` should import from `@bindings/*` directly; everything + // else imports from `@gridfpv/types`. + "baseUrl": ".", + "paths": { + "@bindings/*": ["../bindings/*"] + }, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", From 895b6a2550f991490ff6c1b9d7324d4bf33a5e51 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:07:06 +0000 Subject: [PATCH 006/362] Add WebSocket change-stream + sequence/resume engine (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the v0.4 realtime change stream (protocol.html §3, §9.1): a WebSocket-only `GET /stream` endpoint that, after a client's snapshot, keeps its scoped projection identical to the server's via ordered, sequenced change envelopes. - AppState gains an append-notification primitive (`tokio::sync::Notify`) and `AppState::append(event, recorded_at)` — the single log-write that also wakes every subscribed stream. The control path (#45) reuses it. - `ws.rs` runs one stream per connection: read the one SubscribeRequest, reject a resume cursor older than the bounded retained window (RETAINED_WINDOW = 256 log offsets) with a StreamMessage::ReSnapshotRequired (ErrorCode::StaleCursor), else replay from the cursor by folding the log forward and emit a fresh-value ChangeEnvelope each time the scoped projection changes, then tail new appends via the notify wakeup. - Per-stream sequence (starts at 1, +1 per envelope) is kept distinct from the resume cursor (a log offset, §9.5); the mapping is documented. - New StreamMessage wire type (Change | ReSnapshotRequired) with regenerated bindings. Delta-vs-fresh distinction is wired per projection (§9.2); actual delta encodings stay deferred to #59 (fresh-value only for now). - Integration tests spin the router on an ephemeral localhost port with axum::serve + tokio-tungstenite (no Docker): ordered streaming from a cursor, mid-cursor resume, too-old-cursor re-snapshot, no-op folds, and malformed-subscribe close. Deterministic. `cargo xtask ci` passes (incl. gen drift). Part of #43. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 49 +++- bindings/StreamMessage.ts | 17 ++ crates/server/Cargo.toml | 17 +- crates/server/src/app.rs | 58 ++++- crates/server/src/lib.rs | 24 +- crates/server/src/stream.rs | 50 ++++ crates/server/src/ws.rs | 431 +++++++++++++++++++++++++++++++ crates/server/tests/ws_stream.rs | 290 +++++++++++++++++++++ 8 files changed, 914 insertions(+), 22 deletions(-) create mode 100644 bindings/StreamMessage.ts create mode 100644 crates/server/src/ws.rs create mode 100644 crates/server/tests/ws_stream.rs diff --git a/Cargo.lock b/Cargo.lock index 0a87e71..4520ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -72,8 +73,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -494,6 +497,7 @@ name = "gridfpv-server" version = "0.1.0" dependencies = [ "axum", + "futures-util", "gridfpv-engine", "gridfpv-events", "gridfpv-projection", @@ -502,6 +506,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-tungstenite 0.24.0", "tower", "ts-rs", ] @@ -1191,7 +1196,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.21.0", "tungstenite 0.21.0", "url", ] @@ -1619,6 +1624,30 @@ dependencies = [ "tungstenite 0.21.0", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1746,6 +1775,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.29.0" diff --git a/bindings/StreamMessage.ts b/bindings/StreamMessage.ts new file mode 100644 index 0000000..1fb92e0 --- /dev/null +++ b/bindings/StreamMessage.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChangeEnvelope } from "./ChangeEnvelope"; +import type { ProtocolError } from "./ProtocolError"; + +/** + * A frame the server sends down the change-stream WebSocket (protocol.html §3). + * + * Almost every frame is a [`ChangeEnvelope`]; the one exception is the distinguished + * **re-snapshot-required** signal the server sends when a client resumes from a cursor + * older than the bounded retained window (§3 "fall back to re-snapshot"). Modelling + * both as one externally-tagged enum means a client reads a single message type off the + * socket and branches on the tag, rather than guessing whether a frame is data or a + * control signal. + * + * Externally tagged, mapping to a TS discriminated union. + */ +export type StreamMessage = { "Change": ChangeEnvelope } | { "ReSnapshotRequired": ProtocolError }; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index fc68248..a972f82 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,14 +15,19 @@ serde.workspace = true serde_json.workspace = true ts-rs = "12" -# The snapshot HTTP transport (#42). The WS change stream (#43) and the control path -# (#45) build on the same axum `Router` and `AppState` defined in `app.rs`. -axum = "0.8" +# The snapshot HTTP transport (#42) and the WS change stream (#43) build on the same +# axum `Router` and `AppState` defined in `app.rs`; the control path (#45) reuses +# `AppState::append`. `ws` enables axum's WebSocket upgrade extractor. +axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] } [dev-dependencies] -# `tower::ServiceExt::oneshot` drives the router in integration tests with no real -# network or Docker; `http-body-util` collects the response body to assert on it. +# `tower::ServiceExt::oneshot` drives the snapshot router in integration tests with no +# real network or Docker; `http-body-util` collects the response body to assert on it. tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# The WS change-stream integration tests (#43) spin the router on an ephemeral localhost +# port via `axum::serve` and connect with a real WebSocket client (no Docker). +tokio-tungstenite = "0.24" +futures-util = "0.3" diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 9821cad..11b5310 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -83,6 +83,7 @@ use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{LapList, lap_list_marshaled}; use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; +use tokio::sync::Notify; use crate::error::{ErrorCode, ProtocolError}; use crate::live_state::live_state; @@ -142,10 +143,24 @@ pub type SharedLog = Arc>; /// /// Holds the [`SharedLog`] — the single source of truth the snapshot reads fold, the WS /// stream (#43) tails, and the control path (#45) appends through. Cloning an `AppState` -/// clones the `Arc`, so all handlers and future tasks share one log. +/// clones the `Arc`s, so all handlers and tasks share one log and one append signal. +/// +/// # Append notification (the stream wakeup, #43) +/// +/// The change stream is a long-lived task that, after replaying the log tail, must wake +/// the *instant* a new event is appended so it can fold and push the next envelope. A +/// [`tokio::sync::Notify`] is the wakeup: every [`append`](AppState::append) appends to +/// the log and then `notify_waiters()`. A stream that has caught up to the log tail waits +/// on `notified()`; the next append wakes it and it reads the new tail. `Notify` (rather +/// than a `broadcast` channel) carries no payload — the event itself is read back from +/// the log, the one source of truth — so a slow stream can never lag a bounded channel +/// and miss an event; it always re-reads from where it left off. The control path (#45) +/// drives the very same [`append`](AppState::append) so its writes wake every stream. #[derive(Clone)] pub struct AppState { log: SharedLog, + /// Woken on every append so caught-up change streams re-read the log tail. + appended: Arc, } impl AppState { @@ -155,13 +170,17 @@ impl AppState { pub fn new(log: impl EventLog + Send + 'static) -> Self { Self { log: Arc::new(Mutex::new(log)), + appended: Arc::new(Notify::new()), } } /// Build the state from an already-shared log handle — for when the WS stream (#43) /// or control path (#45) needs to share the *same* `Arc>` with the router. pub fn from_shared(log: SharedLog) -> Self { - Self { log } + Self { + log, + appended: Arc::new(Notify::new()), + } } /// The shared log handle, for tasks that tail or append outside the router. @@ -169,10 +188,38 @@ impl AppState { Arc::clone(&self.log) } + /// Append an event to the log **and wake every subscribed change stream** + /// (protocol.html §3) — the one write path the control endpoint (#45) reuses. + /// + /// Locks the log, appends through [`EventSource::append`] (assigning the next dense + /// [`Offset`]), unlocks, then `notify_waiters()` so any stream parked on the log tail + /// wakes and folds the new event into its scope. Returns the assigned offset. + /// + /// The notify happens *after* the lock is released and the append has committed, so a + /// woken stream is guaranteed to see the new event when it re-reads the tail (no woken + /// stream can observe a torn or not-yet-committed write). + pub fn append(&self, event: Event, recorded_at: Option) -> Result { + let offset = { + let mut log = self.log.lock().map_err(|_| { + ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned") + })?; + log.append(event, recorded_at) + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))? + }; + self.appended.notify_waiters(); + Ok(offset) + } + + /// A handle to the append-notification primitive, for the change-stream task to park + /// on between log reads (see the type docs). + pub(crate) fn appended(&self) -> Arc { + Arc::clone(&self.appended) + } + /// Read the whole log into a `Vec` plus the resume [`Cursor`] (the log length /// at read time). A single lock spans the read so the events and the cursor are /// consistent with one another. - fn read(&self) -> Result<(Vec, Cursor), ProtocolError> { + pub(crate) fn read(&self) -> Result<(Vec, Cursor), ProtocolError> { let log = self.log.lock().map_err(|_| { ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned") })?; @@ -200,6 +247,7 @@ pub fn router(state: AppState) -> Router { .route("/snapshot/class/{event}/{class}", get(snapshot_class)) .route("/snapshot/heat/{heat}", get(snapshot_heat)) .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot)) + .route("/stream", get(crate::ws::stream_handler)) .with_state(state) } @@ -341,7 +389,7 @@ async fn snapshot_pilot( } /// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start. -fn first_pass_at(event: &Event) -> Option { +pub(crate) fn first_pass_at(event: &Event) -> Option { match event { Event::Pass(p) if p.gate.is_lap_gate() => Some(p.at), _ => None, @@ -357,7 +405,7 @@ fn first_pass_at(event: &Event) -> Option { /// heat's. Passes carry no heat id (they are raw observations), so attribution is by /// position in the log relative to heat-loop events — the same ordering the engine uses to /// decide which heat consumes a pass (race-engine.html §2). -fn heat_window(events: &[Event], heat: &HeatId) -> Vec { +pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { let mut window = Vec::new(); // `active` tracks whether the cursor is currently inside this heat's span: it opens on // a heat-loop event for `heat` and closes on a heat-loop event for a *different* heat. diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index bc1220f..4381323 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -3,15 +3,17 @@ //! by the Cloud). The wire types are defined here once and generated to TypeScript //! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html. //! -//! # What this crate is, as of #41 / #42 +//! # What this crate is, as of #41 / #42 / #43 //! //! The wire-type surface — the Rust types that *are* the protocol (protocol.html §6: //! "the contract defined once, in Rust") — plus the **read transport** over them: the -//! live race-state projection ([`live_state`], #41) and the axum snapshot endpoints -//! ([`app`], #42). The change stream (#43), auth (#44), and control endpoints (#45) -//! build on the same [`app::AppState`] and [`app::router`]. Defining the types first -//! means every one of those issues — and the frontend protocol client (#49) — builds -//! against a single generated contract that already exists. +//! live race-state projection ([`live_state`], #41), the axum snapshot endpoints +//! ([`app`], #42), and the WebSocket change stream ([`ws`], #43) that keeps a client's +//! scoped projection current after the snapshot. Auth (#44) and control endpoints (#45) +//! build on the same [`app::AppState`] and [`app::router`] — the control path reusing +//! [`app::AppState::append`], the one log-write-plus-stream-wakeup the change stream +//! observes. Defining the types first means every one of those issues — and the frontend +//! protocol client (#49) — builds against a single generated contract that already exists. //! //! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format) //! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring @@ -39,10 +41,11 @@ //! # Deferred (later issues + the doc-reconciliation pass) //! //! - **Exact delta encodings.** [`Change::Delta`](stream::Change::Delta) is a -//! `serde_json::Value` placeholder for now; the precise per-projection delta shapes -//! (a single appended lap, a heat-state transition, …) are pinned when the change -//! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed -//! here, which is what later issues need. +//! `serde_json::Value` placeholder; the change stream ([`ws`], #43) emits only +//! [`Change::FreshValue`](stream::Change::FreshValue) envelopes for now, while wiring +//! the per-projection delta-vs-fresh *distinction* (§9.2) so the precise delta shapes +//! (a single appended lap, a heat-state transition, …) can be pinned in #59 without +//! reshaping the stream or its sequencing. //! - **Scope addressing grammar.** [`app::router`] pins a concrete REST addressing over //! the four resources (event/class/heat/pilot, §4); the richer filter grammar (§9.6), //! and the log-level *class* filter (the log carries no class tag yet), are refined in @@ -60,6 +63,7 @@ pub mod live_state; pub mod scope; pub mod snapshot; pub mod stream; +pub mod ws; use serde::{Deserialize, Serialize}; use ts_rs::TS; diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs index 684fb07..597f58c 100644 --- a/crates/server/src/stream.rs +++ b/crates/server/src/stream.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; +use crate::error::ProtocolError; use crate::snapshot::{ProjectionBody, ProjectionKind}; /// The public **projection sequence number** (protocol.html §3, §9.5): a single @@ -95,6 +96,29 @@ pub struct ChangeEnvelope { pub change: Change, } +/// A frame the server sends down the change-stream WebSocket (protocol.html §3). +/// +/// Almost every frame is a [`ChangeEnvelope`]; the one exception is the distinguished +/// **re-snapshot-required** signal the server sends when a client resumes from a cursor +/// older than the bounded retained window (§3 "fall back to re-snapshot"). Modelling +/// both as one externally-tagged enum means a client reads a single message type off the +/// socket and branches on the tag, rather than guessing whether a frame is data or a +/// control signal. +/// +/// Externally tagged, mapping to a TS discriminated union. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum StreamMessage { + /// One ordered, sequenced projection change to apply (the common case). + Change(ChangeEnvelope), + /// The resume cursor the client presented is older than the server's retained + /// window: the gap cannot be replayed, so the client must fetch a fresh snapshot and + /// resubscribe from its new cursor (protocol.html §3). The carried + /// [`ProtocolError`] always has [`ErrorCode::StaleCursor`](crate::error::ErrorCode::StaleCursor). + /// This is the terminal frame of the stream — no envelopes follow it. + ReSnapshotRequired(ProtocolError), +} + #[cfg(test)] mod tests { use super::*; @@ -140,4 +164,30 @@ mod tests { let back: ChangeEnvelope = serde_json::from_str(&json).unwrap(); assert_eq!(env, back); } + + #[test] + fn stream_message_variants_round_trip() { + use crate::error::{ErrorCode, ProtocolError}; + + let change = StreamMessage::Change(ChangeEnvelope { + sequence: Cursor::new(1), + projection: ProjectionKind::LiveRaceState, + change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState::default())), + }); + let json = serde_json::to_string(&change).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + change + ); + + let resnap = StreamMessage::ReSnapshotRequired(ProtocolError::new( + ErrorCode::StaleCursor, + "cursor 3 is below the retained window (oldest replayable offset 8)", + )); + let json = serde_json::to_string(&resnap).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + resnap + ); + } } diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs new file mode 100644 index 0000000..514646f --- /dev/null +++ b/crates/server/src/ws.rs @@ -0,0 +1,431 @@ +//! The WebSocket change-stream transport (protocol.html §3, §9.1) — issue #43. +//! +//! After fetching a [`Snapshot`](crate::snapshot::Snapshot) over HTTP (#42), a client +//! opens this single WebSocket (`GET /stream`), sends one +//! [`SubscribeRequest`](crate::scope::SubscribeRequest), and receives an ordered run of +//! [`StreamMessage`]s that keep its scoped projection identical to the server's. v0.4 is +//! **WebSocket-only** (§9.1); the SSE read-only fallback (§9.1 open decision) is not built. +//! +//! # The subscribe protocol +//! +//! 1. Client → server: exactly one text frame, a JSON [`SubscribeRequest`] — the +//! [`Scope`](crate::scope::Scope) it wants and an optional `from` resume cursor. +//! 2. Server → client: a stream of JSON [`StreamMessage`] text frames, each either a +//! [`ChangeEnvelope`](crate::stream::ChangeEnvelope) or the terminal +//! [`StreamMessage::ReSnapshotRequired`] signal. +//! +//! A malformed or missing subscribe frame closes the socket with a +//! [`ProtocolError`]-bearing close frame ([`ErrorCode::BadRequest`]); no auth is enforced +//! here (#44 layers it in front of this handler). +//! +//! # Per-stream sequence vs the resume cursor (protocol.html §3, §9.5) +//! +//! Two distinct integers, deliberately: +//! +//! - The **resume cursor** ([`Cursor`]) a client presents in `from` is a **log offset** — +//! the same value the [`Snapshot`](crate::snapshot::Snapshot) handed it (the log length +//! at snapshot time, i.e. the offset of the first event *after* the snapshot). It names a +//! position in the Director's private append-only log (§1, §9.5) and is what makes resume +//! work across reconnects: a fresh connection re-presents the last offset it had caught +//! up to. +//! - The **per-stream sequence** ([`ChangeEnvelope::sequence`]) is this stream's own +//! public ordering: a monotonic integer **starting at 1**, incremented by one for every +//! envelope *this connection* emits (§3 "increases by one per envelope"). It is not the +//! log offset — one log append can fan out into several projection changes or none the +//! scope subscribes to, so the sequence advances independently of the offset. +//! +//! The mapping between them: as the engine folds the log forward it tracks the log offset +//! it has consumed up to; whenever the scoped projection's value *changes* it emits one +//! envelope, assigns it the next per-stream sequence (1, 2, 3, …), and remembers the offset +//! at which it emitted. A client persists *both* — it renders by sequence order and resumes +//! by the offset cursor. (The two coincide numerically only by accident; the protocol keeps +//! them separate so the log offset can stay a private detail while the sequence is the +//! public contract.) +//! +//! # The bounded retained window + re-snapshot (protocol.html §3, §9.3) +//! +//! The Director is memory-bounded (§9.3 "keep it simple — one event"): it retains a +//! window of [`RETAINED_WINDOW`] log offsets behind the current tail. A resume `from` +//! cursor older than `tail - RETAINED_WINDOW` cannot be replayed, so instead of streaming +//! a hole the server sends a single [`StreamMessage::ReSnapshotRequired`] +//! ([`ErrorCode::StaleCursor`]) and closes — the client re-snapshots and resubscribes from +//! the fresh cursor (§3 "re-snapshot is always correct because projections are +//! recomputable"). A `from` of `0` (or `None`, a fresh subscribe) is *never* stale: replay +//! from the start of the log is always in-window by definition. +//! +//! # Guarantees (protocol.html §3) +//! +//! - **Total order, gap-free.** Envelopes carry strictly increasing per-stream sequences +//! 1, 2, 3, … with no gaps; a client applying them in order converges to server state. +//! - **Idempotent, at-least-once with exactly-once effect.** Applying an envelope is keyed +//! by its sequence, so a redelivery after a flaky reconnect is a no-op. The engine folds +//! from the log (the source of truth) on every wakeup, so a resumed stream re-derives the +//! same envelopes for the same offsets — overlap on resume is harmless. +//! +//! # Delta vs fresh-value — deferred encodings (protocol.html §9.2) +//! +//! Every envelope this engine emits today is a [`Change::FreshValue`]: the whole recomputed +//! [`ProjectionBody`]. The per-projection delta *encodings* (a single appended lap, a +//! heat-state transition) are deferred to #59 — [`Change::Delta`] is still an opaque +//! placeholder. What is wired now is the **distinction** §9.2 fixes: [`ScopeProjection`] +//! tags each scope as *delta-preferring* (the append-heavy lap-list and live-state scopes, +//! where #59 will encode incremental deltas) or *fresh-value* (a cheap re-fold like a heat +//! result after a marshaling correction, which stays a fresh value). The engine records +//! that preference per envelope so #59 can swap the encoding in without reshaping the +//! stream or its sequencing. + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use gridfpv_events::{CompetitorRef, Event}; +use gridfpv_projection::{LapList, lap_list_marshaled}; + +use crate::app::{AppState, heat_window}; +use crate::error::{ErrorCode, ProtocolError}; +use crate::live_state::live_state; +use crate::scope::Scope; +use crate::snapshot::{ProjectionBody, ProjectionKind}; +use crate::stream::{Change, ChangeEnvelope, Cursor, StreamMessage}; + +/// How many log offsets behind the tail the Director keeps replayable before forcing a +/// re-snapshot (protocol.html §3, §9.3). +/// +/// "Keep it simple" (§9.3): a fixed window, not a byte/time budget. A resume cursor older +/// than `tail - RETAINED_WINDOW` is answered with [`StreamMessage::ReSnapshotRequired`]. +/// The Cloud (later) keeps a much larger window over durable storage; the Director's is +/// deliberately small and memory-bounded. The value is generous enough that a brief network +/// blip resumes seamlessly while an offset far in the past is correctly rejected. +pub const RETAINED_WINDOW: u64 = 256; + +/// Whether a projection is append-heavy (so #59 will encode incremental **deltas**) or a +/// cheap re-fold re-sent whole as a **fresh value** (protocol.html §9.2). +/// +/// Recorded per envelope so the delta-vs-fresh *distinction* is wired even though every +/// envelope is a fresh value today (see the module docs). The engine consults it only to +/// document intent for #59; it does not yet change the encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Encoding { + /// Append-heavy: a lap list grows by laps, live-state by passes — #59 sends deltas. + DeltaPreferring, + /// Cheap to recompute and re-sent whole (a re-folded result / ranking / outcome, e.g. + /// after a marshaling correction, §9.2) — stays a fresh value even after #59. + FreshValue, +} + +impl Encoding { + /// The delta-vs-fresh preference §9.2 fixes for a projection kind: the append-heavy + /// live-state and lap-list are delta-preferring; the re-folded result, ranking, and + /// event outcome stay fresh values (a single marshaling correction re-folds the whole + /// thing, so a delta would be no smaller). + fn of(kind: ProjectionKind) -> Self { + match kind { + ProjectionKind::LiveRaceState | ProjectionKind::LapList => Encoding::DeltaPreferring, + ProjectionKind::HeatResult | ProjectionKind::Ranking | ProjectionKind::EventOutcome => { + Encoding::FreshValue + } + } + } +} + +/// The projection a [`Scope`] folds to on the change stream, plus the delta-vs-fresh +/// preference §9.2 fixes for it. +/// +/// This binds each of the four addressable scopes (§4) to the one projection its change +/// stream advances, mirroring the snapshot folds in [`crate::app`] so a subscriber and a +/// snapshot of the same scope agree. +struct ScopeProjection { + kind: ProjectionKind, + encoding: Encoding, +} + +impl ScopeProjection { + /// The projection + encoding preference a scope's change stream uses. + fn of(scope: &Scope) -> Self { + // Event / class fold the whole-event live race-state (the class log filter is + // still deferred, exactly as the snapshot path notes); the heat scope folds its + // own window's live race-state (the tightest, lowest-latency scope); the pilot + // scope folds that pilot's lap list across the event. + let kind = match scope { + Scope::Event { .. } | Scope::Class { .. } | Scope::Heat { .. } => { + ProjectionKind::LiveRaceState + } + Scope::Pilot { .. } => ProjectionKind::LapList, + }; + ScopeProjection { + kind, + encoding: Encoding::of(kind), + } + } + + /// Fold the scope's projection over the event prefix `events` (offsets `0..n`). + /// + /// Pure: the same prefix always yields the same body, so folding `0..n` then `0..n+1` + /// and comparing tells the engine whether offset `n` *changed* the projection (and thus + /// whether to emit an envelope). Reuses the same fold helpers as the snapshot path so a + /// subscriber and a snapshot of the same scope converge to the same value. + fn fold(scope: &Scope, events: &[Event]) -> Option { + match scope { + Scope::Event { .. } | Scope::Class { .. } => { + Some(ProjectionBody::LiveRaceState(live_state(events))) + } + Scope::Heat { heat } => { + // Only fold once the heat exists in the log; before that the scope has no + // value to stream (the snapshot would 404). + let scheduled = events + .iter() + .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if h == heat)); + if !scheduled { + return None; + } + let window = heat_window(events, heat); + Some(ProjectionBody::LiveRaceState(live_state(&window))) + } + Scope::Pilot { pilot, .. } => { + let full = + lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + let pilot_ref = CompetitorRef(pilot.0.clone()); + let competitors: Vec<_> = full + .competitors + .into_iter() + .filter(|c| c.competitor.competitor == pilot_ref) + .collect(); + if competitors.is_empty() { + return None; + } + Some(ProjectionBody::LapList(LapList { competitors })) + } + } + } +} + +/// `GET /stream` — upgrade to the change-stream WebSocket (protocol.html §3, §9.1). +/// +/// The handler upgrades the connection and hands it to [`run_stream`]; auth (#44) and +/// control command handling (#45) layer on separately — this is the read stream only. +pub async fn stream_handler(ws: WebSocketUpgrade, State(state): State) -> Response { + ws.on_upgrade(move |socket| run_stream(socket, state)) +} + +/// Drive one subscribed change stream over an upgraded socket (protocol.html §3). +/// +/// Reads the one [`SubscribeRequest`], then runs the replay-then-tail loop until the client +/// disconnects, the cursor is stale, or a send fails. +async fn run_stream(mut socket: WebSocket, state: AppState) { + // 1. The client's single subscribe frame. + let request = match recv_subscribe(&mut socket).await { + Ok(req) => req, + Err(err) => { + close_with(&mut socket, err).await; + return; + } + }; + + let projection = ScopeProjection::of(&request.scope); + // The resume cursor is a log offset (see the module docs); `None` / 0 means "from the + // start of the log". + let from = request.from.map(|c| c.seq).unwrap_or(0); + + // 2. Stale-cursor check against the bounded retained window. + let tail = match state.read() { + Ok((_, cursor)) => cursor.seq, + Err(err) => { + close_with(&mut socket, err).await; + return; + } + }; + let oldest_replayable = tail.saturating_sub(RETAINED_WINDOW); + if from > 0 && from < oldest_replayable { + let _ = send_message( + &mut socket, + &StreamMessage::ReSnapshotRequired(ProtocolError::new( + ErrorCode::StaleCursor, + format!( + "resume cursor {from} is below the retained window \ + (oldest replayable offset {oldest_replayable})" + ), + )), + ) + .await; + return; + } + + // 3. Replay-then-tail. `Engine` folds the log forward from offset `from`, emitting a + // fresh-value envelope each time the scoped projection changes, advancing the per-stream + // sequence. `applied_offset` is how far into the log it has folded. + let mut engine = Engine::new(request.scope, projection, from); + let appended = state.appended(); + + loop { + // Enrol the wakeup in the waiter list *before* reading the log so an append that + // lands between the read and the await is not lost. `Notified::enable()` registers + // the waiter immediately (rather than on first poll), so a `notify_waiters()` firing + // any time after this line wakes this future — even one that fires before `select!` + // polls it. Without this, an append in the gap between the read and the await would + // wake nobody and the stream would park until the *next* append (a missed update). + let mut wake = std::pin::pin!(appended.notified()); + wake.as_mut().enable(); + + // Pull the current log and fold any new events into envelopes. + let events = match state.read() { + Ok((events, _)) => events, + Err(err) => { + close_with(&mut socket, err).await; + return; + } + }; + for message in engine.advance(&events) { + if send_message(&mut socket, &message).await.is_err() { + return; // client gone + } + } + + // Park until the next append (the pinned `wake`) or the client drops the socket. + tokio::select! { + _ = &mut wake => {} + // Drain client frames so a close is observed promptly; the client sends + // nothing else on the read stream. + frame = socket.recv() => { + match frame { + Some(Ok(Message::Close(_))) | None => return, + Some(Ok(_)) => continue, // ignore stray frames, re-read the log + Some(Err(_)) => return, + } + } + } + } +} + +/// The fold-and-sequence engine for one stream (protocol.html §3). +/// +/// Holds the scope, its projection/encoding, the per-stream sequence counter, the log +/// offset it has folded up to, and the last projection value it emitted. [`advance`] folds +/// any newly-appended events and yields the envelopes whose projection value changed. +struct Engine { + scope: Scope, + projection: ScopeProjection, + /// The next per-stream sequence to assign (starts at 1, §3). + next_seq: u64, + /// The log offset the engine has folded through (exclusive upper bound). Starts at the + /// resume `from`; advances to the log length as events are consumed. + applied_offset: u64, + /// The last projection body emitted, to suppress re-emitting an unchanged fold. + last_emitted: Option, +} + +impl Engine { + fn new(scope: Scope, projection: ScopeProjection, from: u64) -> Self { + Self { + scope, + projection, + next_seq: 1, + applied_offset: from, + // Seed with the projection *at* the resume point so the first envelope reflects + // a change *after* `from`, not a re-send of what the snapshot already carried. + last_emitted: None, + } + } + + /// Fold any events appended since the last call into ordered envelopes. + /// + /// For each new offset `applied_offset..events.len()` it folds the scope over the prefix + /// `0..=offset`; when the folded body differs from the last one emitted it produces one + /// envelope (fresh value, the next sequence). Walking offset by offset keeps the + /// per-stream sequence a faithful "one bump per projection change" and the order total. + fn advance(&mut self, events: &[Event]) -> Vec { + let mut out = Vec::new(); + let len = events.len() as u64; + + // On the *first* fold from a resume point > 0, seed `last_emitted` with the + // projection value at `from` so we only emit changes strictly after the cursor + // (the snapshot already carried the value at `from`). For a fresh subscribe + // (`from == 0`) there is nothing prior, so the first non-empty fold is emitted. + if self.last_emitted.is_none() && self.applied_offset > 0 { + let prefix = &events[..(self.applied_offset as usize).min(events.len())]; + self.last_emitted = ScopeProjection::fold(&self.scope, prefix); + } + + while self.applied_offset < len { + self.applied_offset += 1; + let prefix = &events[..self.applied_offset as usize]; + let body = ScopeProjection::fold(&self.scope, prefix); + if let Some(body) = body { + if self.last_emitted.as_ref() != Some(&body) { + out.push(StreamMessage::Change(self.envelope(body.clone()))); + self.last_emitted = Some(body); + } + } + } + out + } + + /// Wrap a folded projection body in the next sequenced envelope. + /// + /// Every envelope is a [`Change::FreshValue`] today; the [`Encoding`] preference is + /// recorded for #59 (see the module docs) but does not yet change the wire shape. The + /// `kind` is taken from the body so a delta envelope (#59) and a fresh value name the + /// same projection. + fn envelope(&mut self, body: ProjectionBody) -> ChangeEnvelope { + let sequence = Cursor::new(self.next_seq); + self.next_seq += 1; + let _ = self.projection.encoding; // wired for #59; fresh-value for now + let projection = body.kind(); + debug_assert_eq!( + projection, self.projection.kind, + "a scope's folded body must match its declared projection kind" + ); + ChangeEnvelope { + sequence, + projection, + change: Change::FreshValue(body), + } + } +} + +/// Receive and parse the client's single [`SubscribeRequest`] (protocol.html §3, §4). +/// +/// The first text frame must be a JSON [`SubscribeRequest`]; anything else (a binary +/// frame, an early close, an unparseable body) is a [`ErrorCode::BadRequest`]. +async fn recv_subscribe( + socket: &mut WebSocket, +) -> Result { + match socket.recv().await { + Some(Ok(Message::Text(text))) => serde_json::from_str(&text).map_err(|e| { + ProtocolError::new(ErrorCode::BadRequest, format!("malformed subscribe: {e}")) + }), + Some(Ok(Message::Binary(bytes))) => serde_json::from_slice(&bytes).map_err(|e| { + ProtocolError::new(ErrorCode::BadRequest, format!("malformed subscribe: {e}")) + }), + Some(Ok(_)) => Err(ProtocolError::new( + ErrorCode::BadRequest, + "expected a SubscribeRequest text frame first", + )), + Some(Err(e)) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("websocket error before subscribe: {e}"), + )), + None => Err(ProtocolError::new( + ErrorCode::BadRequest, + "connection closed before a SubscribeRequest was sent", + )), + } +} + +/// Serialise and send one [`StreamMessage`] as a JSON text frame. +async fn send_message(socket: &mut WebSocket, message: &StreamMessage) -> Result<(), ()> { + let json = serde_json::to_string(message).map_err(|_| ())?; + socket + .send(Message::Text(json.into())) + .await + .map_err(|_| ()) +} + +/// Close the socket carrying a [`ProtocolError`] in the close frame body (best-effort). +async fn close_with(socket: &mut WebSocket, err: ProtocolError) { + let reason = serde_json::to_string(&err).unwrap_or_else(|_| err.message.clone()); + let _ = socket + .send(Message::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: reason.into(), + }))) + .await; +} diff --git a/crates/server/tests/ws_stream.rs b/crates/server/tests/ws_stream.rs new file mode 100644 index 0000000..5165670 --- /dev/null +++ b/crates/server/tests/ws_stream.rs @@ -0,0 +1,290 @@ +//! WebSocket change-stream integration tests (protocol.html §3) — issue #43. +//! +//! These spin the real [`router`] on an ephemeral `127.0.0.1` port with `axum::serve` and +//! drive it with a real WebSocket client (`tokio-tungstenite`) — no Docker, no mocks of the +//! transport. Each test: +//! +//! 1. builds an [`AppState`] over an [`InMemoryLog`], keeping a clone to append through; +//! 2. serves [`router`] in a background task on an OS-assigned port; +//! 3. connects, sends a [`SubscribeRequest`], and appends events via +//! [`AppState::append`] (the same write path the control endpoint #45 will use); +//! 4. asserts the client receives ordered, gap-free [`ChangeEnvelope`]s converging to the +//! server's folded state. +//! +//! Determinism: appends are explicit and the client awaits each expected frame, so there is +//! no reliance on timing — a short `timeout` only guards against a hang on a missing frame. + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition}; +use gridfpv_server::app::{AppState, router}; +use gridfpv_server::scope::{EventId, Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, ProjectionBody}; +use gridfpv_server::stream::{Change, Cursor, StreamMessage}; +use gridfpv_storage::InMemoryLog; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +type Ws = WebSocketStream>; + +/// Serve `router(state)` on an ephemeral port; return the `ws://…/stream` URL and the +/// server task's join handle (dropped at test end, which aborts the task). +async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(state); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("ws://{addr}/stream"), handle) +} + +/// Connect to the stream endpoint and send a subscribe frame. +async fn subscribe(url: &str, request: &SubscribeRequest) -> Ws { + let (mut ws, _) = connect_async(url).await.unwrap(); + let json = serde_json::to_string(request).unwrap(); + ws.send(Message::text(json)).await.unwrap(); + ws +} + +/// Await the next [`StreamMessage`] text frame (with a timeout so a missing frame fails the +/// test rather than hanging). +async fn next_message(ws: &mut Ws) -> StreamMessage { + let frame = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for a stream frame") + .expect("stream closed unexpectedly") + .expect("websocket error"); + match frame { + Message::Text(text) => serde_json::from_str(&text).expect("parse StreamMessage"), + Message::Close(frame) => panic!("server closed the stream: {frame:?}"), + other => panic!("expected a text frame, got {other:?}"), + } +} + +/// The `LiveRaceState` body of a `Change` stream message, asserting it is a fresh value +/// (the only encoding emitted today, §9.2 deferral). +fn live_body(message: &StreamMessage) -> &gridfpv_server::snapshot::LiveRaceState { + match message { + StreamMessage::Change(env) => match &env.change { + Change::FreshValue(ProjectionBody::LiveRaceState(ls)) => ls, + other => panic!("expected a fresh-value live-state, got {other:?}"), + }, + other => panic!("expected a Change, got {other:?}"), + } +} + +fn heat_scheduled(id: &str, lineup: &[&str]) -> Event { + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(), + } +} + +fn heat_changed(id: &str, transition: HeatTransition) -> Event { + Event::HeatStateChanged { + heat: HeatId(id.into()), + transition, + } +} + +fn event_scope() -> Scope { + Scope::Event { + event: EventId("spring-cup".into()), + } +} + +/// Subscribe fresh (from the start), append events, and assert the client receives ordered, +/// gap-free envelopes whose final state matches the server's fold. +#[tokio::test] +async fn streams_ordered_envelopes_from_a_fresh_subscribe() { + let state = AppState::new(InMemoryLog::default()); + let (url, _server) = serve(state.clone()).await; + + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: None, + }, + ) + .await; + + // Append a heat scheduling then drive it through the loop. Each append that *changes* + // the live-state fold yields one envelope. + state + .append(heat_scheduled("q-1", &["A", "B"]), None) + .unwrap(); + let m1 = next_message(&mut ws).await; + assert_eq!(seq(&m1), 1, "first envelope is per-stream sequence 1"); + assert_eq!(live_body(&m1).current_heat, Some(HeatId("q-1".into()))); + assert_eq!(live_body(&m1).phase, HeatPhase::Scheduled); + + state + .append(heat_changed("q-1", HeatTransition::Staged), None) + .unwrap(); + let m2 = next_message(&mut ws).await; + assert_eq!(seq(&m2), 2, "sequence increments by one, gap-free"); + assert_eq!(live_body(&m2).phase, HeatPhase::Staged); + + state + .append(heat_changed("q-1", HeatTransition::Armed), None) + .unwrap(); + let m3 = next_message(&mut ws).await; + assert_eq!(seq(&m3), 3); + assert_eq!(live_body(&m3).phase, HeatPhase::Armed); + + state + .append(heat_changed("q-1", HeatTransition::Running), None) + .unwrap(); + let m4 = next_message(&mut ws).await; + assert_eq!(seq(&m4), 4); + assert_eq!(live_body(&m4).phase, HeatPhase::Running); +} + +/// Resume from a mid-stream cursor: a second connection presenting an in-window log offset +/// receives only the changes *after* that offset, with its own per-stream sequence restarting +/// at 1. +#[tokio::test] +async fn resume_from_a_mid_cursor_replays_only_the_tail() { + let state = AppState::new(InMemoryLog::default()); + let (url, _server) = serve(state.clone()).await; + + // Pre-load three events (offsets 0,1,2 → log length 3). + state + .append(heat_scheduled("q-1", &["A", "B"]), None) + .unwrap(); + state + .append(heat_changed("q-1", HeatTransition::Staged), None) + .unwrap(); + state + .append(heat_changed("q-1", HeatTransition::Armed), None) + .unwrap(); + + // Resume from offset 2 (the snapshot cursor a client would have after the Staged + // change): it must NOT replay the earlier two, only fold offset 2 onward. + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: Some(Cursor::new(2)), + }, + ) + .await; + + // Offset 2 (the Armed change) is the first event after the cursor → first envelope. + let m1 = next_message(&mut ws).await; + assert_eq!(seq(&m1), 1, "a resumed stream's own sequence restarts at 1"); + assert_eq!(live_body(&m1).phase, HeatPhase::Armed); + + // A further append continues the resumed stream in order. + state + .append(heat_changed("q-1", HeatTransition::Running), None) + .unwrap(); + let m2 = next_message(&mut ws).await; + assert_eq!(seq(&m2), 2); + assert_eq!(live_body(&m2).phase, HeatPhase::Running); +} + +/// A resume cursor older than the bounded retained window gets the re-snapshot-required +/// signal instead of a replay (protocol.html §3, §9.3). +#[tokio::test] +async fn too_old_cursor_requires_re_snapshot() { + let state = AppState::new(InMemoryLog::default()); + let (url, _server) = serve(state.clone()).await; + + // Push the log tail well past the retained window so a low cursor is out of range. + let tail = gridfpv_server::ws::RETAINED_WINDOW + 50; + for _ in 0..tail { + state.append(heat_scheduled("q-1", &["A"]), None).unwrap(); + } + + // Resume from offset 1 — far below `tail - RETAINED_WINDOW`. + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: Some(Cursor::new(1)), + }, + ) + .await; + + match next_message(&mut ws).await { + StreamMessage::ReSnapshotRequired(err) => { + assert_eq!(err.code, gridfpv_server::error::ErrorCode::StaleCursor); + } + other => panic!("expected ReSnapshotRequired, got {other:?}"), + } +} + +/// A change that does not alter the scoped projection emits no envelope (the engine emits +/// one envelope per *change*, keeping the per-stream sequence a faithful change count). +#[tokio::test] +async fn unchanged_fold_emits_no_envelope() { + let state = AppState::new(InMemoryLog::default()); + let (url, _server) = serve(state.clone()).await; + + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: None, + }, + ) + .await; + + state + .append(heat_scheduled("q-1", &["A", "B"]), None) + .unwrap(); + let m1 = next_message(&mut ws).await; + assert_eq!(seq(&m1), 1); + assert_eq!(live_body(&m1).phase, HeatPhase::Scheduled); + + // Re-scheduling the same heat with the same lineup folds to the same live state → no + // new envelope. A following Running transition *does* change it and must arrive as + // sequence 2 (proving sequence 2 was not silently consumed by the no-op append). + state + .append(heat_scheduled("q-1", &["A", "B"]), None) + .unwrap(); + state + .append(heat_changed("q-1", HeatTransition::Running), None) + .unwrap(); + let m2 = next_message(&mut ws).await; + assert_eq!(seq(&m2), 2, "the no-op append did not consume a sequence"); + assert_eq!(live_body(&m2).phase, HeatPhase::Running); +} + +/// A malformed first frame closes the socket with a BadRequest close (no panic, no hang). +#[tokio::test] +async fn malformed_subscribe_closes_the_socket() { + let state = AppState::new(InMemoryLog::default()); + let (url, _server) = serve(state).await; + + let (mut ws, _) = connect_async(&url).await.unwrap(); + ws.send(Message::text("not a subscribe request")) + .await + .unwrap(); + + // The server replies with a close frame and ends the stream. + let closed = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(frame) = ws.next().await { + if matches!(frame, Ok(Message::Close(_))) || frame.is_err() { + return true; + } + } + true // stream ended + }) + .await + .expect("timed out waiting for the socket to close"); + assert!(closed); +} + +/// The per-stream sequence of a `Change` message. +fn seq(message: &StreamMessage) -> u64 { + match message { + StreamMessage::Change(env) => env.sequence.seq, + other => panic!("expected a Change, got {other:?}"), + } +} From 2c711541b58558487a9755473b6c68a029817db9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:10:07 +0000 Subject: [PATCH 007/362] Build the shared Svelte component library v1 (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out @gridfpv/components from its placeholder set into the v1 "one component, three presentations" library (docs/clients.html §3, §5): every widget takes typed projection data from @gridfpv/types as props (never a hand-written wire shape) and themes off a single design-token layer, so the RD console, spectator PWA, and OBS overlays compose the same components with per-context token overrides. Components (props → source type): - Leaderboard — result: HeatResult (places: Placement[]) - StandingsTable — entries: RankEntry[] - BracketTree — bracket: Bracket (local view-model; the wire has no bracket projection yet — derived from heats/results, documented in bracket.ts; swap for a generated type later) - HeatSheet — state: LiveRaceState (active_pilots + PilotProgress + running_order + phase) - RaceClock — elapsedMs/remainingMs (pure presentational) - PilotCard — progress: PilotProgress Design tokens (tokens.css): one token set (color, spacing, type scale, radii) as CSS custom properties, with .gridfpv-overlay and .gridfpv-dense context classes re-pointing the same tokens; exported as @gridfpv/components/tokens.css. a11y: semantic tables/lists, roles, accessible labels; responsive. Shared pure formatters (format.ts: formatClock/formatMicros/formatMetric/ medalFor) keep "how a lap time reads" in one place for components + tests. Tests: vitest + @testing-library/svelte render each component from typed fixtures and assert the key fields; 20 component tests + 27 total across the frontend. The rd-console App.svelte becomes a small playground that composes all six widgets (the heavy console UI is #51+). Also adds PilotProgress to the @gridfpv/types barrel (it existed in bindings/ but was unexported). Confined to frontend/; no Rust/xtask/bindings/Cargo changes. npm run build / check / lint / test all green. Part of #50. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 125 +- frontend/apps/rd-console/src/main.ts | 3 + frontend/package-lock.json | 1088 ++++++++++++++++- frontend/packages/components/package.json | 12 +- .../components/src/BracketTree.svelte | 89 ++ .../packages/components/src/HeatSheet.svelte | 115 ++ .../components/src/Leaderboard.svelte | 110 +- .../packages/components/src/PilotCard.svelte | 96 ++ .../packages/components/src/RaceClock.svelte | 46 +- .../components/src/StandingsTable.svelte | 99 ++ frontend/packages/components/src/bracket.ts | 52 + frontend/packages/components/src/format.ts | 54 + frontend/packages/components/src/index.ts | 26 +- frontend/packages/components/src/tokens.css | 101 ++ .../components/tests/BracketTree.test.ts | 28 + .../components/tests/HeatSheet.test.ts | 30 + .../components/tests/Leaderboard.test.ts | 36 + .../components/tests/PilotCard.test.ts | 28 + .../components/tests/RaceClock.test.ts | 21 + .../components/tests/StandingsTable.test.ts | 23 + .../packages/components/tests/fixtures.ts | 90 ++ .../packages/components/tests/format.test.ts | 47 + .../packages/components/tsconfig.test.json | 11 + frontend/packages/components/vitest.config.ts | 28 + frontend/packages/components/vitest.setup.ts | 3 + frontend/packages/types/src/generated.ts | 1 + 26 files changed, 2277 insertions(+), 85 deletions(-) create mode 100644 frontend/packages/components/src/BracketTree.svelte create mode 100644 frontend/packages/components/src/HeatSheet.svelte create mode 100644 frontend/packages/components/src/PilotCard.svelte create mode 100644 frontend/packages/components/src/StandingsTable.svelte create mode 100644 frontend/packages/components/src/bracket.ts create mode 100644 frontend/packages/components/src/format.ts create mode 100644 frontend/packages/components/src/tokens.css create mode 100644 frontend/packages/components/tests/BracketTree.test.ts create mode 100644 frontend/packages/components/tests/HeatSheet.test.ts create mode 100644 frontend/packages/components/tests/Leaderboard.test.ts create mode 100644 frontend/packages/components/tests/PilotCard.test.ts create mode 100644 frontend/packages/components/tests/RaceClock.test.ts create mode 100644 frontend/packages/components/tests/StandingsTable.test.ts create mode 100644 frontend/packages/components/tests/fixtures.ts create mode 100644 frontend/packages/components/tests/format.test.ts create mode 100644 frontend/packages/components/tsconfig.test.json create mode 100644 frontend/packages/components/vitest.config.ts create mode 100644 frontend/packages/components/vitest.setup.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index a233fc8..9a9bd75 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -1,16 +1,23 @@ -
+

GridFPV — RD console

-

Scaffold shell. Connected to {client.baseUrl} (stub).

+

+ Component library playground. Connected to {client.baseUrl} (stub). +

Race clock

- +
+ + +
+
+ +
+

Pilot card

+ +
+ +
+

Heat sheet (live)

+

Leaderboard

- + +
+ +
+

Standings

+ +
+ +
+

Bracket

+
diff --git a/frontend/apps/rd-console/src/main.ts b/frontend/apps/rd-console/src/main.ts index dbf00e0..89f9e54 100644 --- a/frontend/apps/rd-console/src/main.ts +++ b/frontend/apps/rd-console/src/main.ts @@ -1,4 +1,7 @@ import { mount } from 'svelte'; +// The component library's design tokens — imported once per surface so every +// @gridfpv/components widget themes off the same CSS custom properties. +import '@gridfpv/components/tokens.css'; import App from './App.svelte'; const target = document.getElementById('app'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13d06dd..6167016 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -181,6 +181,177 @@ } } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1335,6 +1506,110 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1774,6 +2049,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1791,6 +2076,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1834,6 +2129,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1872,6 +2174,20 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1972,6 +2288,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1994,6 +2323,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2007,6 +2343,41 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2025,6 +2396,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent-js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", @@ -2059,6 +2437,26 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -2066,33 +2464,117 @@ "dev": true, "license": "MIT" }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", @@ -2481,6 +2963,23 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2496,6 +2995,55 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2522,6 +3070,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2532,6 +3093,102 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2569,6 +3226,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2592,6 +3259,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2609,6 +3283,13 @@ "dev": true, "license": "ISC" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", @@ -2632,6 +3313,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2741,6 +3463,23 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2751,6 +3490,49 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2807,6 +3589,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2870,6 +3659,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3081,6 +3883,34 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3091,6 +3921,13 @@ "node": ">=6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3105,6 +3942,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3160,6 +4011,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3173,6 +4031,26 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -3247,6 +4125,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3417,6 +4308,13 @@ "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3478,6 +4376,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4138,6 +5082,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4181,6 +5186,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", @@ -4221,10 +5265,14 @@ "devDependencies": { "@sveltejs/package": "^2.5.8", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.6", + "jsdom": "^25.0.1", "svelte": "^5.16.0", "svelte-check": "^4.1.1", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^2.1.8" }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/frontend/packages/components/package.json b/frontend/packages/components/package.json index e6bc42f..870f779 100644 --- a/frontend/packages/components/package.json +++ b/frontend/packages/components/package.json @@ -12,14 +12,16 @@ "types": "./dist/index.d.ts", "svelte": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./tokens.css": "./dist/tokens.css" }, "files": [ "dist" ], "scripts": { "build": "svelte-package --input src --output dist && svelte-check --tsconfig ./tsconfig.json", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json && svelte-check --tsconfig ./tsconfig.test.json", + "test": "vitest run" }, "dependencies": { "@gridfpv/types": "*" @@ -30,9 +32,13 @@ "devDependencies": { "@sveltejs/package": "^2.5.8", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.6", + "jsdom": "^25.0.1", "svelte": "^5.16.0", "svelte-check": "^4.1.1", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^2.1.8" } } diff --git a/frontend/packages/components/src/BracketTree.svelte b/frontend/packages/components/src/BracketTree.svelte new file mode 100644 index 0000000..1ce16e2 --- /dev/null +++ b/frontend/packages/components/src/BracketTree.svelte @@ -0,0 +1,89 @@ + + +
+ {#each bracket.rounds as round (round.name)} +
+

{round.name}

+ {#each round.matches as match, mi (match.heat ?? round.name + '-' + mi)} +
+ {#each match.slots as slot, si (si)} +
+ {slot.label ?? slot.competitor ?? 'TBD'} + {#if slot.winner}{/if} +
+ {/each} +
+ {/each} +
+ {/each} +
+ + diff --git a/frontend/packages/components/src/HeatSheet.svelte b/frontend/packages/components/src/HeatSheet.svelte new file mode 100644 index 0000000..553c8b3 --- /dev/null +++ b/frontend/packages/components/src/HeatSheet.svelte @@ -0,0 +1,115 @@ + + +
+
+

{state.current_heat ?? 'No heat'}

+ {state.phase} +
+
    + {#each order as ref, i (ref)} + {@const p = progressByRef.get(ref)} +
  1. + {i + 1} + {names[ref] ?? ref} + {p ? p.laps_completed : 0} laps + {formatMicros(p?.last_lap_micros)} +
  2. + {/each} +
+
+ + diff --git a/frontend/packages/components/src/Leaderboard.svelte b/frontend/packages/components/src/Leaderboard.svelte index 59bbfc5..816ce35 100644 --- a/frontend/packages/components/src/Leaderboard.svelte +++ b/frontend/packages/components/src/Leaderboard.svelte @@ -1,38 +1,100 @@ -
    - {#each result.places as place (place.competitor.competitor)} -
  1. - {place.position} - {place.competitor.competitor} -
  2. - {/each} -
+ + + + + + + + + + + {#each result.places as place (place.competitor.adapter + '/' + place.competitor.competitor)} + {@const medal = medalFor(place.position)} + + + + + + + {/each} + +
PosPilotLaps{metricLabel}
{place.position}{place.competitor.competitor}{place.laps}{formatMetric(place.metric)}
diff --git a/frontend/packages/components/src/PilotCard.svelte b/frontend/packages/components/src/PilotCard.svelte new file mode 100644 index 0000000..9350fae --- /dev/null +++ b/frontend/packages/components/src/PilotCard.svelte @@ -0,0 +1,96 @@ + + +
+ {#if position !== undefined} + {position} + {/if} +
+ {label} +
+
+
Laps
+
{progress.laps_completed}
+
+
+
Last lap
+
{formatMicros(progress.last_lap_micros)}
+
+
+
+
+ + diff --git a/frontend/packages/components/src/RaceClock.svelte b/frontend/packages/components/src/RaceClock.svelte index 59232b8..199307c 100644 --- a/frontend/packages/components/src/RaceClock.svelte +++ b/frontend/packages/components/src/RaceClock.svelte @@ -1,24 +1,42 @@ -{label} + diff --git a/frontend/packages/components/src/StandingsTable.svelte b/frontend/packages/components/src/StandingsTable.svelte new file mode 100644 index 0000000..321709f --- /dev/null +++ b/frontend/packages/components/src/StandingsTable.svelte @@ -0,0 +1,99 @@ + + + + {#if caption} + + {/if} + + + + + + + + {#each entries as entry (entry.competitor)} + {@const medal = medalFor(entry.position)} + + + + + {/each} + +
{caption}
PosPilot
{entry.position}{entry.competitor}
+ + diff --git a/frontend/packages/components/src/bracket.ts b/frontend/packages/components/src/bracket.ts new file mode 100644 index 0000000..47c08fb --- /dev/null +++ b/frontend/packages/components/src/bracket.ts @@ -0,0 +1,52 @@ +/** + * Bracket view-model — a small, presentation-only shape for `BracketTree`. + * + * ── Why a view-model lives here ────────────────────────────────────────────── + * The protocol wire (the ts-rs `bindings/`) does NOT yet expose a single-elim + * bracket projection: it has `HeatId`, `HeatResult`/`Placement`, `CompletedHeat`, + * and `CompetitorRef`, but no "rounds → heats → winners" tree. Rather than + * hand-write a wire type (forbidden — `@gridfpv/types` is the only seam to the + * generated contract), `BracketTree` takes this explicit **view-model** that a + * caller derives from heats + their results. When the server grows a real + * bracket projection, this type is replaced by the generated one and the + * component's prop simply re-points at `@gridfpv/types`. + * + * The shape intentionally mirrors the wire vocabulary it is built from: + * - slots carry a `CompetitorRef` (the source-local handle, as `Placement` + * and `RankEntry` do) plus an optional human `label`; + * - a match's `winner` is a `CompetitorRef`, matching what a `HeatResult`'s + * top `Placement` yields. + */ + +import type { CompetitorRef, HeatId } from '@gridfpv/types'; + +/** One competitor's seat in a bracket match. */ +export interface BracketSlot { + /** Source-local competitor handle (as carried on the wire), if seated. */ + competitor?: CompetitorRef; + /** Optional display label; falls back to `competitor` when absent. */ + label?: string; + /** Marks the slot that advanced from this match (the heat's winner). */ + winner?: boolean; +} + +/** A single heat in the bracket (two or more seats racing for advancement). */ +export interface BracketMatch { + /** The heat this match corresponds to in the log, if scheduled. */ + heat?: HeatId; + /** The seats in this match, in lineup order. */ + slots: BracketSlot[]; +} + +/** One round of the bracket (e.g. quarterfinals), earliest round first. */ +export interface BracketRound { + /** Human round name (e.g. `'Quarterfinals'`, `'Final'`). */ + name: string; + /** The matches in this round, top to bottom. */ + matches: BracketMatch[]; +} + +/** A single-elimination bracket: rounds in order, last round is the final. */ +export interface Bracket { + rounds: BracketRound[]; +} diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts new file mode 100644 index 0000000..e2fc2b6 --- /dev/null +++ b/frontend/packages/components/src/format.ts @@ -0,0 +1,54 @@ +/** + * Pure presentational formatters shared across the component library. + * + * Durations on the wire are microseconds (`bigint`, source clock — see + * `@bindings/SourceTime` / `Lap.duration_micros`); these turn them into the + * compact strings the widgets show. Kept framework-pure so components and tests + * share one source of truth for "how a lap time reads". + */ + +import type { Metric } from '@gridfpv/types'; + +/** Format a clock duration in **milliseconds** as `M:SS.mmm` (e.g. `1:23.456`). */ +export function formatClock(ms: number): string { + const totalMs = Math.max(0, Math.floor(ms)); + const minutes = Math.floor(totalMs / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const millis = totalMs % 1000; + return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; +} + +/** + * Format a lap/split duration given in **microseconds** (the wire unit) as + * `S.mmm` seconds, or minutes:seconds when ≥ 60s. `null`/`undefined` → `'—'`. + */ +export function formatMicros(micros: bigint | null | undefined): string { + if (micros === null || micros === undefined) return '—'; + const totalMs = Number(micros / 1000n); + if (totalMs >= 60000) return formatClock(totalMs); + const seconds = Math.floor(totalMs / 1000); + const millis = totalMs % 1000; + return `${seconds}.${String(millis).padStart(3, '0')}`; +} + +/** + * Render a scoring [`Metric`] for display — the condition-specific deciding + * value carried on a `Placement`. Durations become `S.mmm`; the time-of-event + * metrics (`LastLapAt`, `ReachedAt`) carry a source timestamp we surface as a + * coarse marker rather than inventing a wall-clock format here. + */ +export function formatMetric(metric: Metric): string { + if ('BestLapMicros' in metric) return formatMicros(metric.BestLapMicros); + if ('BestConsecutiveMicros' in metric) return formatMicros(metric.BestConsecutiveMicros); + if ('LastLapAt' in metric) return metric.LastLapAt === null ? '—' : 'banked'; + if ('ReachedAt' in metric) return metric.ReachedAt === null ? '—' : 'reached'; + return '—'; +} + +/** The medal token name for a 1-based finishing position, or `null` past 3rd. */ +export function medalFor(position: number): 'gold' | 'silver' | 'bronze' | null { + if (position === 1) return 'gold'; + if (position === 2) return 'silver'; + if (position === 3) return 'bronze'; + return null; +} diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts index 7ebb4e4..21031e0 100644 --- a/frontend/packages/components/src/index.ts +++ b/frontend/packages/components/src/index.ts @@ -2,9 +2,29 @@ * @gridfpv/components — shared GridFPV Svelte 5 component library. * * Race-domain widgets built once and themed per surface (RD console, spectator - * PWA, OBS overlays), per docs/clients.html §3. Placeholder set for now; later - * issues flesh out the real leaderboard, bracket tree, heat sheet, pilot card, - * standings table, etc. + * PWA, OBS overlays), per docs/clients.html §3 ("one component, three + * presentations") and §5 (one token set, three contexts). Every component takes + * **typed projection data from `@gridfpv/types`** as props — never a hand-written + * wire shape — and styles itself only through the design tokens in `tokens.css`, + * so a surface re-themes the whole set by overriding CSS custom properties. + * + * The library is framework-pure: it depends on `@gridfpv/types` (types only) and + * Svelte, never on `@gridfpv/protocol-client`. Apps wire data in. + * + * Design tokens ship as a stylesheet a surface imports once: + * import '@gridfpv/components/tokens.css'; */ + +// Components export { default as Leaderboard } from './Leaderboard.svelte'; +export { default as StandingsTable } from './StandingsTable.svelte'; +export { default as BracketTree } from './BracketTree.svelte'; +export { default as HeatSheet } from './HeatSheet.svelte'; export { default as RaceClock } from './RaceClock.svelte'; +export { default as PilotCard } from './PilotCard.svelte'; + +// Presentational helpers (pure, framework-agnostic) +export { formatClock, formatMicros, formatMetric, medalFor } from './format.js'; + +// View-model types +export type { Bracket, BracketRound, BracketMatch, BracketSlot } from './bracket.js'; diff --git a/frontend/packages/components/src/tokens.css b/frontend/packages/components/src/tokens.css new file mode 100644 index 0000000..a9d19ca --- /dev/null +++ b/frontend/packages/components/src/tokens.css @@ -0,0 +1,101 @@ +/** + * GridFPV design tokens — one token set, three contexts (docs/clients.html §5). + * + * Every component styles itself only through these CSS custom properties, so a + * surface re-themes the whole library by overriding tokens on a wrapping element + * (or `:root`) — no per-component CSS overrides. The defaults below are the + * "RD console" baseline (readable on a light surface); the `.gridfpv-overlay` + * and `.gridfpv-dense` context classes below re-point the same tokens for the + * OBS overlay (transparent, large, high-contrast) and dense-table contexts. + * + * Token groups: + * --gf-color-* palette (surface, text, accent, position medals, state) + * --gf-space-* spacing scale (4px base) + * --gf-font-* type family + size scale + weights + * --gf-radius-* corner radii + * + * Import this once per surface (e.g. in an app's entry CSS): + * import '@gridfpv/components/tokens.css'; + */ + +:where(.gridfpv-root), +:root { + /* ── Palette ─────────────────────────────────────────────────────────── */ + --gf-color-surface: #ffffff; + --gf-color-surface-alt: #f4f5f7; + --gf-color-border: #d9dce1; + --gf-color-text: #1a1d21; + --gf-color-text-muted: #6b7280; + --gf-color-accent: #2563eb; + --gf-color-accent-contrast: #ffffff; + + /* Position / medal colors, used by leaderboards + standings. */ + --gf-color-gold: #d4af37; + --gf-color-silver: #9aa0a6; + --gf-color-bronze: #b06a2c; + + /* State colors (live phase, win/leader, penalty/DQ). */ + --gf-color-live: #16a34a; + --gf-color-leader: #2563eb; + --gf-color-warn: #b45309; + --gf-color-danger: #dc2626; + + /* ── Spacing scale (4px base) ────────────────────────────────────────── */ + --gf-space-1: 0.25rem; + --gf-space-2: 0.5rem; + --gf-space-3: 0.75rem; + --gf-space-4: 1rem; + --gf-space-6: 1.5rem; + --gf-space-8: 2rem; + + /* ── Typography ──────────────────────────────────────────────────────── */ + --gf-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --gf-font-mono: ui-monospace, 'SF Mono', 'Roboto Mono', monospace; + + --gf-font-size-xs: 0.75rem; + --gf-font-size-sm: 0.875rem; + --gf-font-size-md: 1rem; + --gf-font-size-lg: 1.25rem; + --gf-font-size-xl: 1.75rem; + --gf-font-size-2xl: 2.5rem; + + --gf-font-weight-normal: 400; + --gf-font-weight-medium: 600; + --gf-font-weight-bold: 700; + + /* ── Radii ───────────────────────────────────────────────────────────── */ + --gf-radius-sm: 4px; + --gf-radius-md: 8px; +} + +/** + * Overlay context: transparent background, oversized + high-contrast text for a + * broadcast keyer. Apply `class="gridfpv-overlay"` on a wrapping element. + */ +:where(.gridfpv-overlay) { + --gf-color-surface: transparent; + --gf-color-surface-alt: rgba(0, 0, 0, 0.35); + --gf-color-border: rgba(255, 255, 255, 0.25); + --gf-color-text: #ffffff; + --gf-color-text-muted: rgba(255, 255, 255, 0.7); + --gf-color-accent: #38bdf8; + + --gf-font-size-sm: 1rem; + --gf-font-size-md: 1.25rem; + --gf-font-size-lg: 1.75rem; + --gf-font-size-xl: 2.5rem; + --gf-font-size-2xl: 3.5rem; +} + +/** + * Dense context: tighter spacing + smaller type for the data-dense RD console + * tables. Apply `class="gridfpv-dense"` on a wrapping element. + */ +:where(.gridfpv-dense) { + --gf-space-2: 0.375rem; + --gf-space-3: 0.5rem; + --gf-space-4: 0.625rem; + + --gf-font-size-sm: 0.8125rem; + --gf-font-size-md: 0.875rem; +} diff --git a/frontend/packages/components/tests/BracketTree.test.ts b/frontend/packages/components/tests/BracketTree.test.ts new file mode 100644 index 0000000..0e509e2 --- /dev/null +++ b/frontend/packages/components/tests/BracketTree.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import BracketTree from '../src/BracketTree.svelte'; +import { bracket } from './fixtures.js'; + +describe('BracketTree', () => { + it('renders each round, its matches, and the seated competitors', () => { + const { container } = render(BracketTree, { bracket }); + + expect(screen.getByText('Semifinals')).toBeInTheDocument(); + expect(screen.getByText('Final')).toBeInTheDocument(); + + // Competitors across the rounds. + expect(screen.getByText('DANA')).toBeInTheDocument(); + expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); + + // Three matches total (2 semis + 1 final). + expect(container.querySelectorAll('.match').length).toBe(3); + }); + + it('marks the advancing seat in each match', () => { + const { container } = render(BracketTree, { bracket }); + const winners = container.querySelectorAll('.slot.winner'); + // One winner per match. + expect(winners.length).toBe(3); + winners.forEach((w) => expect(w).toHaveAttribute('aria-selected', 'true')); + }); +}); diff --git a/frontend/packages/components/tests/HeatSheet.test.ts b/frontend/packages/components/tests/HeatSheet.test.ts new file mode 100644 index 0000000..4138aa2 --- /dev/null +++ b/frontend/packages/components/tests/HeatSheet.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import HeatSheet from '../src/HeatSheet.svelte'; +import { liveState } from './fixtures.js'; + +describe('HeatSheet', () => { + it('renders the heat id, phase, and each pilot with live laps', () => { + render(HeatSheet, { state: liveState }); + + expect(screen.getByText('heat-1')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); + + expect(screen.getByText('ALICE')).toBeInTheDocument(); + expect(screen.getByText('BOB')).toBeInTheDocument(); + expect(screen.getByText('CARMEN')).toBeInTheDocument(); + + // ALICE leads the running order with 3 laps. + const aliceRow = screen.getByText('ALICE').closest('li'); + expect(aliceRow!).toHaveTextContent('3 laps'); + + // CARMEN has no completed last lap → em dash. + const carmenRow = screen.getByText('CARMEN').closest('li'); + expect(carmenRow!).toHaveTextContent('—'); + }); + + it('maps competitor refs to display names when provided', () => { + render(HeatSheet, { state: liveState, names: { ALICE: 'Alice A.' } }); + expect(screen.getByText('Alice A.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/components/tests/Leaderboard.test.ts b/frontend/packages/components/tests/Leaderboard.test.ts new file mode 100644 index 0000000..176a76d --- /dev/null +++ b/frontend/packages/components/tests/Leaderboard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Leaderboard from '../src/Leaderboard.svelte'; +import { heatResult } from './fixtures.js'; + +describe('Leaderboard', () => { + it('renders every competitor with position, laps, and metric', () => { + render(Leaderboard, { result: heatResult, metricLabel: 'Best lap' }); + + // All three pilots present. + expect(screen.getByText('ALICE')).toBeInTheDocument(); + expect(screen.getByText('BOB')).toBeInTheDocument(); + expect(screen.getByText('CARMEN')).toBeInTheDocument(); + + // The custom metric column heading. + expect(screen.getByText('Best lap')).toBeInTheDocument(); + + // ALICE's row: position 1, 3 laps, best lap 41.250. + const aliceRow = screen.getByText('ALICE').closest('tr'); + expect(aliceRow).not.toBeNull(); + expect(aliceRow!).toHaveTextContent('1'); + expect(aliceRow!).toHaveTextContent('3'); + expect(aliceRow!).toHaveTextContent('41.250'); + + // CARMEN has a null metric → em dash. + const carmenRow = screen.getByText('CARMEN').closest('tr'); + expect(carmenRow!).toHaveTextContent('—'); + }); + + it('marks the podium positions with medal data attributes', () => { + const { container } = render(Leaderboard, { result: heatResult }); + expect(container.querySelector('tr[data-medal="gold"]')).not.toBeNull(); + expect(container.querySelector('tr[data-medal="silver"]')).not.toBeNull(); + expect(container.querySelector('tr[data-medal="bronze"]')).not.toBeNull(); + }); +}); diff --git a/frontend/packages/components/tests/PilotCard.test.ts b/frontend/packages/components/tests/PilotCard.test.ts new file mode 100644 index 0000000..c0cbf6b --- /dev/null +++ b/frontend/packages/components/tests/PilotCard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import PilotCard from '../src/PilotCard.svelte'; +import { pilotProgress } from './fixtures.js'; + +describe('PilotCard', () => { + it('shows identity, lap count, and last lap from PilotProgress', () => { + render(PilotCard, { progress: pilotProgress, position: 1 }); + + expect(screen.getByText('ALICE')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); // laps_completed + expect(screen.getByText('41.250')).toBeInTheDocument(); // last lap (µs → s) + expect(screen.getByLabelText('Position 1')).toBeInTheDocument(); + }); + + it('prefers an explicit display name over the source-local ref', () => { + render(PilotCard, { progress: pilotProgress, name: 'Alice A.' }); + expect(screen.getByText('Alice A.')).toBeInTheDocument(); + expect(screen.queryByText('ALICE')).toBeNull(); + }); + + it('renders an em dash when no lap has been completed', () => { + render(PilotCard, { + progress: { competitor: 'NEW', laps_completed: 0, last_lap_micros: undefined } + }); + expect(screen.getByText('—')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/components/tests/RaceClock.test.ts b/frontend/packages/components/tests/RaceClock.test.ts new file mode 100644 index 0000000..ca2a377 --- /dev/null +++ b/frontend/packages/components/tests/RaceClock.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import RaceClock from '../src/RaceClock.svelte'; + +describe('RaceClock', () => { + it('formats elapsed milliseconds as M:SS.mmm', () => { + render(RaceClock, { elapsedMs: 83_456 }); + expect(screen.getByText('1:23.456')).toBeInTheDocument(); + }); + + it('renders a countdown and marks remaining mode when given remainingMs', () => { + const { container } = render(RaceClock, { remainingMs: 5_000 }); + expect(screen.getByText('0:05.000')).toBeInTheDocument(); + expect(container.querySelector('[data-mode="remaining"]')).not.toBeNull(); + }); + + it('exposes an accessible timer label', () => { + render(RaceClock, { elapsedMs: 0, label: 'Lap timer' }); + expect(screen.getByRole('timer')).toHaveAttribute('aria-label', 'Lap timer: 0:00.000'); + }); +}); diff --git a/frontend/packages/components/tests/StandingsTable.test.ts b/frontend/packages/components/tests/StandingsTable.test.ts new file mode 100644 index 0000000..3201735 --- /dev/null +++ b/frontend/packages/components/tests/StandingsTable.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import StandingsTable from '../src/StandingsTable.svelte'; +import { standings } from './fixtures.js'; + +describe('StandingsTable', () => { + it('renders each ranking entry with its tie-aware position', () => { + render(StandingsTable, { entries: standings, caption: 'Open — overall' }); + + expect(screen.getByText('Open — overall')).toBeInTheDocument(); + expect(screen.getByText('ALICE')).toBeInTheDocument(); + + // The two pilots tied at position 2 both show "2". + const bobRow = screen.getByText('BOB').closest('tr'); + const bob2Row = screen.getByText('BOB2').closest('tr'); + expect(bobRow!).toHaveTextContent('2'); + expect(bob2Row!).toHaveTextContent('2'); + + // The next distinct entry skips to 4 (competition ranking). + const carmenRow = screen.getByText('CARMEN').closest('tr'); + expect(carmenRow!).toHaveTextContent('4'); + }); +}); diff --git a/frontend/packages/components/tests/fixtures.ts b/frontend/packages/components/tests/fixtures.ts new file mode 100644 index 0000000..9f2220b --- /dev/null +++ b/frontend/packages/components/tests/fixtures.ts @@ -0,0 +1,90 @@ +/** + * Typed test fixtures — representative projection data for the component tests. + * + * Every fixture is typed against `@gridfpv/types` (the generated-bindings seam) + * or the local bracket view-model, so a contract change surfaces as a type error + * here rather than silently passing a wrong shape into a component. + */ +import type { + HeatResult, + RankEntry, + LiveRaceState, + PilotProgress, + CompetitorRef +} from '@gridfpv/types'; +import type { Bracket } from '../src/bracket.js'; + +export const heatResult: HeatResult = { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_250_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 42_100_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, + position: 3, + laps: 2, + metric: { BestLapMicros: null } + } + ] +}; + +export const standings: RankEntry[] = [ + { competitor: 'ALICE', position: 1 }, + { competitor: 'BOB', position: 2 }, + { competitor: 'BOB2', position: 2 }, + { competitor: 'CARMEN', position: 4 } +]; + +export const pilotProgress: PilotProgress = { + competitor: 'ALICE', + laps_completed: 2, + last_lap_micros: 41_250_000n +}; + +export const liveState: LiveRaceState = { + current_heat: 'heat-1', + phase: 'Running', + active_pilots: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[], + progress: [ + { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000n }, + { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000n }, + { competitor: 'CARMEN', laps_completed: 2, last_lap_micros: undefined } + ], + running_order: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[] +}; + +export const bracket: Bracket = { + rounds: [ + { + name: 'Semifinals', + matches: [ + { + heat: 'sf-1', + slots: [{ competitor: 'ALICE', winner: true }, { competitor: 'DANA' }] + }, + { + heat: 'sf-2', + slots: [{ competitor: 'BOB', winner: true }, { competitor: 'CARMEN' }] + } + ] + }, + { + name: 'Final', + matches: [ + { + heat: 'final', + slots: [{ competitor: 'ALICE', winner: true }, { competitor: 'BOB' }] + } + ] + } + ] +}; diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts new file mode 100644 index 0000000..61049ac --- /dev/null +++ b/frontend/packages/components/tests/format.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { formatClock, formatMicros, formatMetric, medalFor } from '../src/format.js'; +import type { Metric } from '@gridfpv/types'; + +describe('formatClock', () => { + it('formats milliseconds as M:SS.mmm', () => { + expect(formatClock(0)).toBe('0:00.000'); + expect(formatClock(83_456)).toBe('1:23.456'); + expect(formatClock(605_007)).toBe('10:05.007'); + }); + it('clamps negatives to zero', () => { + expect(formatClock(-100)).toBe('0:00.000'); + }); +}); + +describe('formatMicros', () => { + it('renders sub-minute durations as S.mmm seconds', () => { + expect(formatMicros(41_250_000n)).toBe('41.250'); + expect(formatMicros(1_500_000n)).toBe('1.500'); + }); + it('rolls over to M:SS.mmm at or above a minute', () => { + expect(formatMicros(83_456_000n)).toBe('1:23.456'); + }); + it('renders null/undefined as an em dash', () => { + expect(formatMicros(null)).toBe('—'); + expect(formatMicros(undefined)).toBe('—'); + }); +}); + +describe('formatMetric', () => { + it('formats each win-condition metric variant', () => { + expect(formatMetric({ BestLapMicros: 41_250_000n } as Metric)).toBe('41.250'); + expect(formatMetric({ BestConsecutiveMicros: 120_000_000n } as Metric)).toBe('2:00.000'); + expect(formatMetric({ BestLapMicros: null } as Metric)).toBe('—'); + expect(formatMetric({ LastLapAt: 5n } as Metric)).toBe('banked'); + expect(formatMetric({ ReachedAt: null } as Metric)).toBe('—'); + }); +}); + +describe('medalFor', () => { + it('maps the podium and nothing below it', () => { + expect(medalFor(1)).toBe('gold'); + expect(medalFor(2)).toBe('silver'); + expect(medalFor(3)).toBe('bronze'); + expect(medalFor(4)).toBeNull(); + }); +}); diff --git a/frontend/packages/components/tsconfig.test.json b/frontend/packages/components/tsconfig.test.json new file mode 100644 index 0000000..297ae45 --- /dev/null +++ b/frontend/packages/components/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + // Type-checks the component tests (kept out of `src` so svelte-package never + // packages them into `dist`). Run by the `check` script; Vitest runs them at + // runtime via vitest.config.ts. + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["svelte", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["tests", "vitest.setup.ts"] +} diff --git a/frontend/packages/components/vitest.config.ts b/frontend/packages/components/vitest.config.ts new file mode 100644 index 0000000..6d39ad2 --- /dev/null +++ b/frontend/packages/components/vitest.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { svelteTesting } from '@testing-library/svelte/vite'; + +// Components are typed against `@gridfpv/types`, whose source re-exports the +// ts-rs bindings through the `@bindings/*` alias (see tsconfig.base.json). Vitest +// uses Vite's resolver, not tsconfig `paths`, so mirror the alias here pointing at +// the repo-root `bindings/`. Those imports are all `export type`, so nothing of +// the bindings executes — this only satisfies resolution. +export default defineConfig({ + // The components are plain Svelte 5 + plain CSS, so they need no preprocessor. + // Skipping `vitePreprocess()` here avoids Vite's `preprocessCSS` running inside + // the Vitest worker (which trips on a partial Vite environment); production + // builds still go through svelte.config.js's `vitePreprocess()` via svelte-package. + plugins: [svelte({ preprocess: [] }), svelteTesting()], + resolve: { + alias: { + '@bindings': fileURLToPath(new URL('../../../bindings', import.meta.url)) + } + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./vitest.setup.ts'], + include: ['tests/**/*.test.ts'] + } +}); diff --git a/frontend/packages/components/vitest.setup.ts b/frontend/packages/components/vitest.setup.ts new file mode 100644 index 0000000..ae97d62 --- /dev/null +++ b/frontend/packages/components/vitest.setup.ts @@ -0,0 +1,3 @@ +// Extends Vitest's `expect` with jest-dom matchers (toBeInTheDocument, etc.) and +// auto-cleans the rendered DOM between tests (via @testing-library/svelte/vite). +import '@testing-library/jest-dom/vitest'; diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index ebd4462..8d0b8de 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -55,6 +55,7 @@ export type * from '@bindings/Metric'; export type * from '@bindings/Pass'; export type * from '@bindings/Penalty'; export type * from '@bindings/PilotId'; +export type * from '@bindings/PilotProgress'; export type * from '@bindings/Placement'; export type * from '@bindings/ProjectionBody'; export type * from '@bindings/ProjectionKind'; From 2888da82be93fb011325fe3b1bbcec3da9c9e713 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:15:01 +0000 Subject: [PATCH 008/362] Add the RD control write path: validate, append, ack (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the privileged control surface (protocol.html §5): a handler that turns a `Command` into validated, appended events through the one write path (`AppState::append`), plus the bidirectional control endpoints. - `control_handler::apply_command` maps each `Command` to its event: heat-loop commands fold the heat's current `HeatState` (`heat::heat_state`) and reuse `heat::apply` for legality — a legal command appends `HeatStateChanged`, an `IllegalTransition` becomes a `BadRequest` `ProtocolError`, an unknown heat an `UnknownScope` one, and nothing is appended on rejection. `ScheduleHeat` appends `HeatScheduled`; the five marshaling commands append their adjudication events, validating cheap targets (a `LogRef` is a real `Pass`, a heat exists). `Register` is acknowledged as not-yet-modelled (no pilot-binding event in the log; Architecture §9) and appends nothing. - Endpoints: `GET /control` (the bidirectional control WebSocket §5 calls for — command frames up, an ack per command down) and `POST /control` (one-shot request/reply), both driving the same handler, composed onto the router via `control_routes`. - Auth hook for #44: a `ControlAuth` marker extractor every control route demands first, infallible today; #44 swaps in the real RD-role check without touching a handler body. - The resulting state reaches the RD over the read stream, not the ack: the append wakes every parked `/stream` (#43), which re-folds and pushes the change. Tests (no Docker): legal Stage→Arm→Start→Finish→Score appends the right transitions and acks ok; an illegal transition is rejected with the shared error shape and appends nothing; a marshaling command appends its adjudication; and an integration test drives `POST`/`WS` control and asserts a `/stream` subscriber observes the resulting change. Part of #45. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/server/src/app.rs | 8 +- crates/server/src/control_handler.rs | 563 +++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/tests/control.rs | 239 ++++++++++++ 4 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 crates/server/src/control_handler.rs create mode 100644 crates/server/tests/control.rs diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 11b5310..8543334 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -241,14 +241,16 @@ impl AppState { /// The WS stream (#43), auth middleware (#44), and control routes (#45) layer onto this /// same router and state. pub fn router(state: AppState) -> Router { - Router::new() + let read = Router::new() .route("/health", get(|| async { "ok" })) .route("/snapshot/event/{event}", get(snapshot_event)) .route("/snapshot/class/{event}/{class}", get(snapshot_class)) .route("/snapshot/heat/{heat}", get(snapshot_heat)) .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot)) - .route("/stream", get(crate::ws::stream_handler)) - .with_state(state) + .route("/stream", get(crate::ws::stream_handler)); + // The privileged RD control surface (§5) is composed on separately so #44 can wrap + // just these routes in its auth layer. + crate::control_handler::control_routes(read).with_state(state) } /// The projection a heat-scope snapshot returns. Selected by `?projection=…`; defaults to diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs new file mode 100644 index 0000000..fa3cd00 --- /dev/null +++ b/crates/server/src/control_handler.rs @@ -0,0 +1,563 @@ +//! The RD control **write path** (protocol.html §5) — issue #45. +//! +//! [`control`](crate::control) defines the command *vocabulary*; this module is the +//! handler that turns a [`Command`] into validated, appended [`Event`]s and the axum +//! routes that carry it. Control is the one **bidirectional** protocol surface (§5): +//! commands up, [`CommandAck`]s down, on a distinct privileged endpoint — while the +//! *resulting state* flows back down the ordinary read stream (#43), because a command's +//! whole job is **validate → append → ack**. The append goes through the very same +//! [`AppState::append`] the change stream observes, so the moment a command is accepted +//! every subscribed `/stream` re-folds and pushes the new value (see "the resulting state +//! reaches the stream" below). +//! +//! # Command → Event mapping +//! +//! | command group | validation | appended event | +//! |---------------|------------|----------------| +//! | heat-loop (`Stage`/`Arm`/`Start`/`Finish`/`Score`/`Advance`/`Abort`/`Restart`/`Discard`) | [`heat::heat_state`] folds the heat's current state; [`heat::apply`] checks the transition is legal | [`Event::HeatStateChanged`] with the engine-returned [`HeatTransition`](gridfpv_events::HeatTransition) | +//! | [`Command::ScheduleHeat`] | none (it creates the heat) | [`Event::HeatScheduled`] | +//! | [`Command::Register`] | — | **deferred** — see below | +//! | [`Command::VoidDetection`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::DetectionVoided`] | +//! | [`Command::AdjustLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::LapAdjusted`] | +//! | [`Command::InsertLap`] | none (it adds a pass) | [`Event::LapInserted`] | +//! | [`Command::VoidHeat`] | the heat exists in the log | [`Event::HeatVoided`] | +//! | [`Command::ApplyPenalty`] | the heat exists in the log | [`Event::PenaltyApplied`] | +//! +//! ## Legality lives in the engine (reused, not re-implemented) +//! +//! A heat-loop command does **not** re-derive the FSM here: it folds the heat's current +//! [`HeatState`](gridfpv_engine::heat::HeatState) with [`heat::heat_state`] over the log, +//! then calls [`heat::apply`] — the single source of FSM legality (race-engine.html §2). +//! A legal command yields the [`HeatTransition`](gridfpv_events::HeatTransition) to record; +//! an [`IllegalTransition`](gridfpv_engine::heat::IllegalTransition) maps to a +//! [`ProtocolError`] of [`ErrorCode::BadRequest`]. A command on a heat that was never +//! scheduled (no `HeatScheduled` in the log, so `heat_state` is `None`) is rejected with +//! [`ErrorCode::UnknownScope`] — nothing is appended. +//! +//! ## Register is deferred (a model gap, not a protocol gap) +//! +//! The event log (`gridfpv-events`) carries **no pilot-binding event** — there is a +//! [`CompetitorSeen`](gridfpv_events::Event::CompetitorSeen) *adapter observation*, but no +//! event that records "this source competitor *is* this event-scoped pilot" (Architecture +//! §9; the same gap the snapshot path notes for pilot scope). Rather than append a +//! lossy stand-in, [`Command::Register`] is acknowledged as **not yet modelled** with a +//! [`ProtocolError`] of [`ErrorCode::BadRequest`] that names the deferral, and **nothing +//! is appended**. When the registration event lands in the log model this becomes a +//! one-line append like the others; the command vocabulary and endpoint already carry it. +//! +//! # Endpoints — the privileged control channel (protocol.html §5) +//! +//! Both shapes drive the *same* [`apply_command`] handler: +//! +//! - **`GET /control`** — the bidirectional control WebSocket §5 calls for ("another +//! reason the RD wants WebSocket"): the RD sends a stream of JSON [`Command`] frames and +//! receives a JSON [`CommandAck`] per command on the same socket. This is the primary +//! surface. +//! - **`POST /control`** — a one-shot `Command` → `CommandAck` for a simple request/reply +//! caller (a script, a test) that does not want a long-lived socket. +//! +//! # Auth hook for #44 +//! +//! Control is authenticated and Director-local (§5); **this issue does not implement +//! auth**. The seam is the [`ControlAuth`] marker extractor: every control route lists it +//! as its first extractor, so #44 replaces its permissive stub with a real RD-role check +//! (a token/role extractor that rejects an unprivileged caller with +//! [`ErrorCode::Unauthorized`]) **without touching the handlers** — the routes already +//! demand it, the wiring already threads it. Until then it admits every caller (the read +//! paths are equally unauthenticated pre-#44). +//! +//! # How the resulting state reaches the stream (protocol.html §3, §5) +//! +//! The ack carries only success/failure (the [`CommandAck`] shape, §5): the *state* a +//! command produces is **not** echoed in the ack. Instead [`apply_command`] appends through +//! [`AppState::append`], which appends to the one log **and** `notify_waiters()` wakes every +//! parked change stream (#43). A subscriber to the affected scope therefore re-folds the new +//! log tail and pushes the resulting [`ChangeEnvelope`](crate::stream::ChangeEnvelope) — the +//! RD sees the consequence of its own command arrive on the read stream it already holds, in +//! the same total order as every other client (§3). The control test below asserts exactly +//! this: after a control append, a `/stream` subscriber observes the change. + +use axum::Json; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{FromRequestParts, State}; +use axum::http::request::Parts; +use axum::response::Response; +use axum::routing::get; +use axum::{Router, routing::MethodRouter}; +use gridfpv_engine::heat::{self, HeatCommand}; +use gridfpv_events::{Event, HeatId, LogRef}; + +use crate::app::AppState; +use crate::control::{Command, CommandAck}; +use crate::error::{ErrorCode, ProtocolError}; + +/// The **auth seam** for the privileged control path (protocol.html §5), left for #44. +/// +/// An axum extractor every control route demands *before* its handler runs. Today its +/// extraction is infallible — it admits every caller, exactly as the read paths do +/// pre-#44 — so the control path is reachable for this issue's write-path work without +/// auth being implemented here. +/// +/// #44 makes this the single chokepoint: it replaces the permissive +/// [`from_request_parts`](FromRequestParts::from_request_parts) below with a real RD-role +/// check (read the bearer token / session, verify the control role, reject an unprivileged +/// caller with [`ErrorCode::Unauthorized`]). Because every control route already lists +/// `ControlAuth` as its first extractor, that change gates `POST /control` and the +/// `GET /control` upgrade at once **without editing a single handler body**. +#[derive(Debug, Clone, Copy)] +pub struct ControlAuth { + // A private field so `ControlAuth` can only be minted by the extractor (the auth + // chokepoint), never constructed ad hoc by a handler that wants to skip the check. + _private: (), +} + +impl FromRequestParts for ControlAuth +where + S: Send + Sync, +{ + type Rejection = ProtocolError; + + async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { + // #44: read the RD credential from `_parts` (Authorization header / session) and + // return `Err(ProtocolError::new(ErrorCode::Unauthorized, …))` for an unprivileged + // caller. For now control is open, matching the unauthenticated read paths. + Ok(ControlAuth { _private: () }) + } +} + +/// Mount the privileged control routes (protocol.html §5) onto an existing [`Router`]. +/// +/// Adds `GET /control` (the bidirectional control WebSocket) and `POST /control` (the +/// one-shot request/reply). Kept separate from [`crate::app::router`] so the control +/// surface is composed explicitly and #44 can wrap *just* these routes in its auth layer. +pub fn control_routes(router: Router) -> Router { + router.route("/control", control_method_router()) +} + +/// `GET /control` (WS upgrade) + `POST /control` (one-shot) on the one path. +fn control_method_router() -> MethodRouter { + get(control_ws).post(control_post) +} + +/// `POST /control` — a single [`Command`] in the body, one [`CommandAck`] back +/// (protocol.html §5). The simple request/reply control surface. +/// +/// [`ControlAuth`] runs first (the #44 seam); on success the command is dispatched through +/// [`apply_command`] against the shared log. The ack is always `200 OK` with the +/// `ok`/`error` body — a *rejected* command (illegal transition, unknown heat) is a +/// well-formed `CommandAck { ok: false, .. }`, not an HTTP error, so a client reads one +/// uniform shape (the transport-level errors — a poisoned lock — still surface as the +/// shared [`ProtocolError`]). +async fn control_post( + _auth: ControlAuth, + State(state): State, + Json(command): Json, +) -> Json { + Json(apply_command(&state, command)) +} + +/// `GET /control` — upgrade to the bidirectional control WebSocket (protocol.html §5). +/// +/// [`ControlAuth`] gates the upgrade (the #44 seam); the upgraded socket is driven by +/// [`run_control`]. +async fn control_ws( + _auth: ControlAuth, + ws: WebSocketUpgrade, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| run_control(socket, state)) +} + +/// Drive one control socket: read [`Command`] frames, write a [`CommandAck`] per command +/// (protocol.html §5). +/// +/// The RD sends a stream of JSON command frames; for each, [`apply_command`] validates and +/// (on success) appends, and the ack goes straight back on the same socket. A malformed +/// frame is answered with a `CommandAck::failed(BadRequest)` rather than closing the +/// socket, so one bad command does not drop the RD's control session. The loop ends when +/// the client closes or the socket errors. +async fn run_control(mut socket: WebSocket, state: AppState) { + while let Some(frame) = socket.recv().await { + let ack = match frame { + Ok(Message::Text(text)) => match serde_json::from_str::(&text) { + Ok(command) => apply_command(&state, command), + Err(e) => CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + format!("malformed command: {e}"), + )), + }, + Ok(Message::Binary(bytes)) => match serde_json::from_slice::(&bytes) { + Ok(command) => apply_command(&state, command), + Err(e) => CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + format!("malformed command: {e}"), + )), + }, + // Ping/Pong are handled by axum; a Close (or a transport error) ends the session. + Ok(Message::Close(_)) | Err(_) => return, + Ok(_) => continue, + }; + let json = match serde_json::to_string(&ack) { + Ok(json) => json, + Err(_) => return, + }; + if socket.send(Message::Text(json.into())).await.is_err() { + return; // client gone + } + } +} + +/// Validate a [`Command`] against the current log and, on success, append the event(s) it +/// records (protocol.html §5) — the one control write path, shared by both endpoints. +/// +/// Reads the log once to fold current state for validation, dispatches per the +/// command→event table (see the module docs), and appends through [`AppState::append`] +/// (which wakes the change streams). Returns [`CommandAck::ok`] on a successful append, or +/// [`CommandAck::failed`] carrying the shared [`ProtocolError`] — and appends **nothing** +/// — on any rejection. +pub fn apply_command(state: &AppState, command: Command) -> CommandAck { + match command_to_event(state, command) { + Ok(event) => match state.append(event, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + }, + Err(err) => CommandAck::failed(err), + } +} + +/// Validate `command` against the current log and produce the [`Event`] to append, or the +/// [`ProtocolError`] explaining the rejection. Pure with respect to the log: it reads but +/// never writes — the append is [`apply_command`]'s job — so a rejected command leaves the +/// log untouched. +fn command_to_event(state: &AppState, command: Command) -> Result { + match command { + // --- Heat-loop transitions: fold current state, reuse the engine's legality. --- + Command::Stage { heat } => heat_transition(state, heat, HeatCommand::Stage), + Command::Arm { heat } => heat_transition(state, heat, HeatCommand::Arm), + Command::Start { heat } => heat_transition(state, heat, HeatCommand::Start), + Command::Finish { heat } => heat_transition(state, heat, HeatCommand::Finish), + Command::Score { heat } => heat_transition(state, heat, HeatCommand::Score), + Command::Advance { heat } => heat_transition(state, heat, HeatCommand::Advance), + Command::Abort { heat } => heat_transition(state, heat, HeatCommand::Abort), + Command::Restart { heat } => heat_transition(state, heat, HeatCommand::Restart), + Command::Discard { heat } => heat_transition(state, heat, HeatCommand::Discard), + + // --- Scheduling: creates the heat, so no prior-state check. --- + Command::ScheduleHeat { heat, lineup } => Ok(Event::HeatScheduled { heat, lineup }), + + // --- Registration: no binding event in the log model yet (see module docs). --- + Command::Register { .. } => Err(ProtocolError::new( + ErrorCode::BadRequest, + "registration binding is not yet modelled in the event log \ + (Architecture §9; deferred — no pilot-binding event exists to append)", + )), + + // --- Marshaling adjudications: validate targets where cheap, then append. --- + Command::VoidDetection { target } => { + require_pass_target(state, target)?; + Ok(Event::DetectionVoided { target }) + } + Command::AdjustLap { target, at } => { + require_pass_target(state, target)?; + Ok(Event::LapAdjusted { target, at }) + } + Command::InsertLap { + adapter, + competitor, + at, + } => Ok(Event::LapInserted { + adapter, + competitor, + at, + }), + Command::VoidHeat { heat } => { + require_scheduled_heat(state, &heat)?; + Ok(Event::HeatVoided { heat }) + } + Command::ApplyPenalty { + heat, + competitor, + penalty, + } => { + require_scheduled_heat(state, &heat)?; + Ok(Event::PenaltyApplied { + heat, + competitor, + penalty, + }) + } + } +} + +/// Fold the heat's current state from the log, validate `command` against it with the +/// engine's [`heat::apply`], and return the [`Event::HeatStateChanged`] it records. +/// +/// - The heat must have been scheduled (`heat_state` is `Some`), else +/// [`ErrorCode::UnknownScope`]. +/// - The transition must be legal in the current state, else [`ErrorCode::BadRequest`] +/// (the [`IllegalTransition`](gridfpv_engine::heat::IllegalTransition) message). +fn heat_transition( + state: &AppState, + heat: HeatId, + command: HeatCommand, +) -> Result { + let (events, _cursor) = state.read()?; + let current = heat::heat_state(&events, &heat).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no heat scheduled with id {:?}", heat.0), + ) + })?; + let transition = heat::apply(current, command) + .map_err(|illegal| ProtocolError::new(ErrorCode::BadRequest, illegal.to_string()))?; + Ok(Event::HeatStateChanged { heat, transition }) +} + +/// Require that `heat` was scheduled in the log (a `HeatScheduled` for it), else +/// [`ErrorCode::UnknownScope`]. The cheap existence check the marshaling heat commands run. +fn require_scheduled_heat(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let scheduled = events + .iter() + .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if h == heat)); + if scheduled { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no heat scheduled with id {:?}", heat.0), + )) + } +} + +/// Require that `target` names a real [`Pass`](gridfpv_events::Pass) in the log — the cheap +/// target check for the offset-addressed marshaling commands (`VoidDetection`, +/// `AdjustLap`). An out-of-range or non-pass offset is [`ErrorCode::BadRequest`]; nothing +/// is appended. +fn require_pass_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match events.get(target.0 as usize) { + Some(Event::Pass(_)) => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is not a detected pass", target.0), + )), + None => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is out of range", target.0), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_events::{ + AdapterId, CompetitorRef, GateIndex, HeatTransition, Pass, Penalty, SourceTime, + }; + use gridfpv_storage::{EventLog, InMemoryLog}; + + fn heat() -> HeatId { + HeatId("q-1".into()) + } + + /// A state whose log already has `q-1` scheduled. + fn scheduled_state() -> AppState { + let mut log = InMemoryLog::default(); + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + }, + None, + ) + .unwrap(); + AppState::new(log) + } + + fn pass(competitor: &str, at: i64, seq: u64) -> Event { + Event::Pass(Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef(competitor.into()), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + }) + } + + /// (a) A legal Stage→Arm→Start→Finish→Score sequence acks ok and appends the matching + /// `HeatStateChanged` events in order. + #[test] + fn legal_heat_loop_sequence_appends_transitions_and_acks_ok() { + let state = scheduled_state(); + let steps = [ + (Command::Stage { heat: heat() }, HeatTransition::Staged), + (Command::Arm { heat: heat() }, HeatTransition::Armed), + (Command::Start { heat: heat() }, HeatTransition::Running), + (Command::Finish { heat: heat() }, HeatTransition::Finished), + (Command::Score { heat: heat() }, HeatTransition::Scored), + ]; + for (command, _expected) in steps.iter().cloned() { + let ack = apply_command(&state, command); + assert!(ack.ok, "expected ok ack, got {ack:?}"); + assert!(ack.error.is_none()); + } + + // The log now holds the scheduling plus one HeatStateChanged per step, in order. + let (events, _) = state.read().unwrap(); + let transitions: Vec = events + .iter() + .filter_map(|e| match e { + Event::HeatStateChanged { transition, .. } => Some(*transition), + _ => None, + }) + .collect(); + assert_eq!( + transitions, + steps.iter().map(|(_, t)| *t).collect::>(), + ); + } + + /// (b) An illegal transition (Start before Arm) is rejected with the shared error shape + /// and appends nothing. + #[test] + fn illegal_transition_is_rejected_and_appends_nothing() { + let state = scheduled_state(); + let (before, _) = state.read().unwrap(); + + let ack = apply_command(&state, Command::Start { heat: heat() }); + assert!(!ack.ok); + let err = ack.error.expect("a failed ack carries the error"); + assert_eq!(err.code, ErrorCode::BadRequest); + + // Nothing was appended — the log is unchanged. + let (after, _) = state.read().unwrap(); + assert_eq!( + before.len(), + after.len(), + "illegal command appended nothing" + ); + } + + /// A command on a heat that was never scheduled is an UnknownScope rejection. + #[test] + fn command_on_unknown_heat_is_rejected() { + let state = scheduled_state(); + let ack = apply_command( + &state, + Command::Stage { + heat: HeatId("does-not-exist".into()), + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope); + } + + /// `ScheduleHeat` creates the heat with its lineup. + #[test] + fn schedule_heat_appends_heat_scheduled() { + let state = AppState::new(InMemoryLog::default()); + let lineup = vec![CompetitorRef("A".into()), CompetitorRef("B".into())]; + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: lineup.clone(), + }, + ); + assert!(ack.ok); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::HeatScheduled { heat: h, lineup: l } if *h == heat() && *l == lineup + ))); + } + + /// (c) A marshaling command appends the right adjudication event. + #[test] + fn apply_penalty_appends_penalty_event() { + let state = scheduled_state(); + let penalty = Penalty::TimeAdded { micros: 2_000_000 }; + let ack = apply_command( + &state, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty, + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::PenaltyApplied { heat: h, competitor: c, penalty: p } + if *h == heat() && *c == CompetitorRef("A".into()) && *p == penalty + ))); + } + + /// `VoidDetection` validates the target is a real pass, then appends the adjudication. + #[test] + fn void_detection_validates_target_and_appends() { + let mut log = InMemoryLog::default(); + // offset 0: a pass; offset 1: a non-pass. + EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap(); + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: heat(), + lineup: vec![], + }, + None, + ) + .unwrap(); + let state = AppState::new(log); + + // Voiding the pass at offset 0 succeeds and appends. + let ack = apply_command(&state, Command::VoidDetection { target: LogRef(0) }); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::DetectionVoided { target } if *target == LogRef(0))) + ); + + // A non-pass target is rejected and appends nothing. + let (before, _) = state.read().unwrap(); + let ack = apply_command(&state, Command::VoidDetection { target: LogRef(1) }); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len()); + + // An out-of-range target is rejected too. + let ack = apply_command( + &state, + Command::VoidDetection { + target: LogRef(999), + }, + ); + assert!(!ack.ok); + } + + /// `Register` is acknowledged as not-yet-modelled and appends nothing (the model gap). + #[test] + fn register_is_deferred_and_appends_nothing() { + let state = scheduled_state(); + let (before, _) = state.read().unwrap(); + let ack = apply_command( + &state, + Command::Register { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: crate::scope::PilotId("acroace".into()), + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len()); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 4381323..1b6a6f3 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -58,6 +58,7 @@ pub mod app; pub mod control; +pub mod control_handler; pub mod error; pub mod live_state; pub mod scope; diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs new file mode 100644 index 0000000..dfde0ec --- /dev/null +++ b/crates/server/tests/control.rs @@ -0,0 +1,239 @@ +//! RD control write-path integration tests (protocol.html §5) — issue #45. +//! +//! These spin the real [`router`] on an ephemeral `127.0.0.1` port with `axum::serve` and +//! drive the privileged control surface end to end — no Docker, no mocks of the transport: +//! +//! - `POST /control` — one [`Command`] in, one [`CommandAck`] back; +//! - `GET /control` — the bidirectional control WebSocket (commands up, acks down); +//! - and crucially the **read-back**: after a control append, a `/stream` subscriber +//! observes the resulting change (§3, §5 — the resulting state reaches the RD on the read +//! stream, not in the ack). +//! +//! Determinism: every command is explicit and each expected frame is awaited under a short +//! timeout, so there is no reliance on timing. + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use gridfpv_events::{CompetitorRef, HeatId}; +use gridfpv_server::app::{AppState, router}; +use gridfpv_server::control::{Command, CommandAck}; +use gridfpv_server::error::ErrorCode; +use gridfpv_server::scope::{Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, ProjectionBody}; +use gridfpv_server::stream::{Change, StreamMessage}; +use gridfpv_storage::InMemoryLog; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +type Ws = WebSocketStream>; + +/// Serve `router(state)` on an ephemeral port; return the base `http://…` address and the +/// server task handle (dropped at test end, aborting the task). +async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(state); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("{addr}"), handle) +} + +fn heat() -> HeatId { + HeatId("q-1".into()) +} + +/// POST one command to `http://{addr}/control` and return the ack. +async fn post_command(addr: &str, command: &Command) -> CommandAck { + // A tiny manual HTTP/1.1 POST so the test pulls in no extra HTTP client dependency. + let body = serde_json::to_string(command).unwrap(); + let request = format!( + "POST /control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).await.unwrap(); + let json = response + .split("\r\n\r\n") + .nth(1) + .expect("response has a body"); + serde_json::from_str(json).expect("body is a CommandAck") +} + +/// Connect the control WS at `ws://{addr}/control`. +async fn control_ws(addr: &str) -> Ws { + let (ws, _) = connect_async(format!("ws://{addr}/control")).await.unwrap(); + ws +} + +/// Send a command frame on the control socket and await its ack frame. +async fn send_command(ws: &mut Ws, command: &Command) -> CommandAck { + ws.send(Message::text(serde_json::to_string(command).unwrap())) + .await + .unwrap(); + let frame = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for an ack") + .expect("control socket closed") + .expect("websocket error"); + match frame { + Message::Text(text) => serde_json::from_str(&text).expect("parse CommandAck"), + other => panic!("expected a text ack, got {other:?}"), + } +} + +/// Subscribe a `/stream` reader and await the next live-state phase. +async fn subscribe_stream(addr: &str, scope: Scope) -> Ws { + let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap(); + let request = SubscribeRequest { scope, from: None }; + ws.send(Message::text(serde_json::to_string(&request).unwrap())) + .await + .unwrap(); + ws +} + +/// Await the next stream message's live-state phase. +async fn next_phase(ws: &mut Ws) -> HeatPhase { + let frame = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for a stream frame") + .expect("stream closed") + .expect("websocket error"); + let message: StreamMessage = match frame { + Message::Text(text) => serde_json::from_str(&text).unwrap(), + Message::Close(c) => panic!("stream closed: {c:?}"), + other => panic!("expected text, got {other:?}"), + }; + match message { + StreamMessage::Change(env) => match env.change { + Change::FreshValue(ProjectionBody::LiveRaceState(ls)) => ls.phase, + other => panic!("expected live-state, got {other:?}"), + }, + other => panic!("expected a Change, got {other:?}"), + } +} + +/// `POST /control`: a legal heat-loop command acks ok and the resulting `HeatStateChanged` +/// reaches a `/stream` subscriber (the read-back, §5). +#[tokio::test] +async fn post_command_drives_heat_loop_and_reaches_stream() { + let state = AppState::new(InMemoryLog::default()); + let (addr, _server) = serve(state.clone()).await; + + // Schedule the heat, then subscribe so the subscriber starts from the scheduled state. + let ack = post_command( + &addr, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + }, + ) + .await; + assert!(ack.ok, "schedule should ack ok: {ack:?}"); + + let mut stream = subscribe_stream(&addr, Scope::Heat { heat: heat() }).await; + // A fresh subscribe replays the log from the start, so the first envelope is the + // already-scheduled state; consume it before driving new transitions. + assert_eq!(next_phase(&mut stream).await, HeatPhase::Scheduled); + + // Stage via the control path; the subscriber observes the resulting transition. + let ack = post_command(&addr, &Command::Stage { heat: heat() }).await; + assert!(ack.ok, "stage should ack ok: {ack:?}"); + assert_eq!(next_phase(&mut stream).await, HeatPhase::Staged); + + // Arm, again read back off the stream. + let ack = post_command(&addr, &Command::Arm { heat: heat() }).await; + assert!(ack.ok, "arm should ack ok: {ack:?}"); + assert_eq!(next_phase(&mut stream).await, HeatPhase::Armed); +} + +/// `GET /control` (the bidirectional WS): a Stage→Arm→Start sequence acks ok per command, +/// an illegal command is rejected with the shared error shape, and the resulting state is +/// readable on `/stream`. +#[tokio::test] +async fn control_ws_acks_each_command_and_rejects_illegal() { + let state = AppState::new(InMemoryLog::default()); + let (addr, _server) = serve(state.clone()).await; + + let mut control = control_ws(&addr).await; + + // Schedule then subscribe. + let ack = send_command( + &mut control, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + }, + ) + .await; + assert!(ack.ok); + + let mut stream = subscribe_stream(&addr, Scope::Heat { heat: heat() }).await; + // Consume the replayed scheduled state (fresh subscribe replays from the start). + assert_eq!(next_phase(&mut stream).await, HeatPhase::Scheduled); + + // Legal forward path over the same control socket. + for (command, expected) in [ + (Command::Stage { heat: heat() }, HeatPhase::Staged), + (Command::Arm { heat: heat() }, HeatPhase::Armed), + (Command::Start { heat: heat() }, HeatPhase::Running), + ] { + let ack = send_command(&mut control, &command).await; + assert!(ack.ok, "{command:?} should ack ok: {ack:?}"); + assert_eq!(next_phase(&mut stream).await, expected); + } + + // An illegal command (Stage while Running) is a failed ack carrying the shared error. + let ack = send_command(&mut control, &Command::Stage { heat: heat() }).await; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + + // The log still reflects only the legal transitions (nothing was appended for the + // rejected command): the next legal command (Finish) acks ok. + let ack = send_command(&mut control, &Command::Finish { heat: heat() }).await; + assert!(ack.ok, "finish after running should ack ok: {ack:?}"); + // The `Finished` transition lands in the `Landed` live-state phase. + assert_eq!(next_phase(&mut stream).await, HeatPhase::Landed); +} + +/// A malformed control frame is answered with a failed ack, not a dropped socket: the next +/// well-formed command still works on the same session. +#[tokio::test] +async fn control_ws_survives_a_malformed_frame() { + let state = AppState::new(InMemoryLog::default()); + let (addr, _server) = serve(state.clone()).await; + let mut control = control_ws(&addr).await; + + control + .send(Message::text("{not a command}")) + .await + .unwrap(); + let frame = tokio::time::timeout(Duration::from_secs(5), control.next()) + .await + .expect("timed out") + .expect("closed") + .expect("ws error"); + let ack: CommandAck = match frame { + Message::Text(text) => serde_json::from_str(&text).unwrap(), + other => panic!("expected an ack, got {other:?}"), + }; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + + // The session survives — a real command now acks ok. + let ack = send_command( + &mut control, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![], + }, + ) + .await; + assert!(ack.ok); +} From 2b1b4f5fb5ebdd368877c24b218f244ec82b741a Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:26:07 +0000 Subject: [PATCH 009/362] Add RD-token auth, read join-tokens, and contract-version negotiation (#44, #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the auth/authorization gate (#44) and contract-version negotiation plus the uniform error model (#46) on the protocol server. #44 — Auth & authorization (protocol.html §5, §9.4): - New `auth` module with an in-memory `TokenStore` (token -> role-bearing `Session`) on `AppState`: `issue_rd_token()`, read-only `issue_join_token()`, and instant `revoke()`. Tokens are opaque, URL/QR-safe random strings drawn from the OS CSPRNG (`getrandom`) — the store is the sole authority, so revocation is just dropping the session. - Control is gated at the single `ControlAuth` chokepoint: its extractor now requires an `Authorization: Bearer ` resolving to a control- authorized (`Role::Rd`) session; no token / read-only join-token / unknown / revoked all reject with `ProtocolError{Unauthorized}` (HTTP 401 / failed ack) — gating both GET+POST /control without touching the handlers. - Reads stay open on the LAN; a read-only join-token is accepted where presented (it authenticates reads, never control) but absence of a token is not an error. #46 — Contract version + error model (protocol.html §7, §9.7, §9.8): - `MIN_SUPPORTED_CONTRACT_VERSION..=CONTRACT_VERSION` supported band with `is_supported_contract_version` and `ServerHello::for_client` / `refresh_error`. The WS subscribe frame doubles as the connect message, carrying the client's `contract_version` (and an optional read `token`); an out-of-band version gets the "too old/new, please refresh" `VersionMismatch` signal before streaming. - The single shared `ProtocolError`/`ErrorCode` shape is used uniformly across HTTP/WS/control; the WS close path now sends the typed error as a text frame (close reasons exceed the 123-byte control-frame limit). Deferred (noted in module docs): session persistence (in-memory for now) and real signed/HMAC join-tokens (opaque + store-validated for now). Regenerated bindings/SubscribeRequest.ts (new optional fields). `cargo xtask ci` passes (fmt, clippy, tests, gen drift). Part of #44, #46. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 1 + bindings/SubscribeRequest.ts | 25 +- crates/server/Cargo.toml | 5 + crates/server/src/app.rs | 18 ++ crates/server/src/auth.rs | 362 +++++++++++++++++++++++++++ crates/server/src/control_handler.rs | 41 +-- crates/server/src/lib.rs | 81 ++++++ crates/server/src/scope.rs | 33 ++- crates/server/src/ws.rs | 39 ++- crates/server/tests/control.rs | 248 ++++++++++++++++-- crates/server/tests/ws_stream.rs | 8 + 11 files changed, 813 insertions(+), 48 deletions(-) create mode 100644 crates/server/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 4520ded..8bd5c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,7 @@ version = "0.1.0" dependencies = [ "axum", "futures-util", + "getrandom 0.3.4", "gridfpv-engine", "gridfpv-events", "gridfpv-projection", diff --git a/bindings/SubscribeRequest.ts b/bindings/SubscribeRequest.ts index 33512c6..af388a4 100644 --- a/bindings/SubscribeRequest.ts +++ b/bindings/SubscribeRequest.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContractVersion } from "./ContractVersion"; import type { Cursor } from "./Cursor"; import type { Scope } from "./Scope"; @@ -12,6 +13,14 @@ import type { Scope } from "./Scope"; * cursor, fall back to re-snapshot"). If the gap is too old to replay the server * answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the * client re-snapshots. + * The subscribe frame doubles as the **connect message** for the WS read path + * (protocol.html §7): it carries the client's [`ContractVersion`](crate::ContractVersion) + * (`contract_version`) so the server can negotiate the contract band before streaming, + * and an optional read **token** (`token`) — a bearer or read-only join-token — so a + * gated deployment can authenticate the read on the same frame. Both are optional and + * default-absent so an existing fresh subscribe (just a `scope`) is unchanged on the + * wire; a missing `contract_version` is treated as this build's [`CONTRACT_VERSION`] + * (the legacy/unspecified client), and a missing `token` is an anonymous LAN read. */ export type SubscribeRequest = { /** @@ -21,4 +30,18 @@ scope: Scope, /** * The cursor to resume after, or `None` to start fresh from the snapshot. */ -from?: Cursor, }; +from?: Cursor, +/** + * The contract version the client was built against (protocol.html §7). Checked + * against the server's supported band before the stream starts; out of band gets the + * "please refresh" [`VersionMismatch`](crate::error::ErrorCode::VersionMismatch) + * signal. Absent means "unspecified" — treated as the server's own + * [`CONTRACT_VERSION`](crate::CONTRACT_VERSION). + */ +contract_version?: ContractVersion, +/** + * An optional read credential (protocol.html §5): a bearer or read-only join-token. + * Reads are open on the LAN, so this is omitted by an anonymous viewer; when present + * it must be valid (an unknown/revoked token is rejected). + */ +token?: string, }; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index a972f82..987532d 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -21,6 +21,11 @@ ts-rs = "12" axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] } +# Auth (#44): opaque bearer + read-only join tokens are random, unguessable strings. +# `getrandom` draws them straight from the OS CSPRNG — minimal, std-ish, no key +# management. Real signed/HMAC join tokens are a later concern (see `auth` module docs). +getrandom = "0.3" + [dev-dependencies] # `tower::ServiceExt::oneshot` drives the snapshot router in integration tests with no # real network or Docker; `http-body-util` collects the response body to assert on it. diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8543334..6173002 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -85,6 +85,7 @@ use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; use tokio::sync::Notify; +use crate::auth::TokenStore; use crate::error::{ErrorCode, ProtocolError}; use crate::live_state::live_state; use crate::scope::{ClassId, EventId, PilotId}; @@ -161,6 +162,12 @@ pub struct AppState { log: SharedLog, /// Woken on every append so caught-up change streams re-read the log tail. appended: Arc, + /// The auth authority (#44): opaque bearer/join tokens → role-bearing sessions. Shared + /// (it is internally `Arc`'d) so every handler — and the [`ControlAuth`] extractor — + /// consults the same sessions; control reads it through [`AppState::tokens`]. + /// + /// [`ControlAuth`]: crate::control_handler::ControlAuth + tokens: TokenStore, } impl AppState { @@ -171,6 +178,7 @@ impl AppState { Self { log: Arc::new(Mutex::new(log)), appended: Arc::new(Notify::new()), + tokens: TokenStore::new(), } } @@ -180,9 +188,19 @@ impl AppState { Self { log, appended: Arc::new(Notify::new()), + tokens: TokenStore::new(), } } + /// The shared auth token store (#44), for minting/revoking tokens out of band (the RD + /// console issues itself an RD token; an operator issues a join-token QR) and for the + /// [`ControlAuth`] extractor to authenticate a control caller. + /// + /// [`ControlAuth`]: crate::control_handler::ControlAuth + pub fn tokens(&self) -> &TokenStore { + &self.tokens + } + /// The shared log handle, for tasks that tail or append outside the router. pub fn log(&self) -> SharedLog { Arc::clone(&self.log) diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 0000000..10529f3 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,362 @@ +//! Auth & authorization — the policy gate in front of the read/realtime/control +//! contract (protocol.html §5, §9.4) — issue #44. +//! +//! §5 fixes the shape of the gate, not its credential format ("the exact token/session +//! format … is deliberately left open"): the **read** half is open or lightly-tokened +//! on the LAN, while **control** "requires the RD's authenticated role and runs only +//! against the Director". §9.4 resolves the open auth decisions this module implements: +//! +//! - **Opaque bearer tokens** back a server-side [`Session`] carrying a [`Role`]. They +//! are opaque random strings (not self-describing JWTs) so the server is the only +//! authority and a token is **revoked** by deleting its session — no key rotation, no +//! token blacklist, instant effect ([`TokenStore::revoke`]). +//! - A lightweight **read-only join-token** (QR/URL-friendly) authenticates a +//! [`Role::ReadOnly`] session for LAN reads but is **never** accepted on control — a +//! spectator who scans the venue QR can watch, never run the race ([`Role::can_control`]). +//! +//! # How control is gated (the one chokepoint) +//! +//! [`ControlAuth`](crate::control_handler::ControlAuth) is the single extractor every +//! control route demands first. Its body calls [`TokenStore::authenticate_control`] with +//! the request's `Authorization: Bearer …`: a valid **RD-role** token yields the marker, +//! anything else (no header, a read-only/join token, a revoked or unknown token) is +//! [`ProtocolError`] of [`ErrorCode::Unauthorized`] → HTTP 401 / a failed ack. The +//! handlers never see the credential; gating both `GET`+`POST /control` is one call. +//! +//! # How reads are treated +//! +//! Reads stay **open on the LAN** (the §5 default): the snapshot `GET`s and the `/stream` +//! WS subscribe do not *require* a token, matching the pre-#44 behaviour and the "a phone +//! on the venue Wi-Fi just watches" use case. A read-only **join-token** is *accepted* +//! where presented (it authenticates a [`Role::ReadOnly`] session) so the same surface +//! works unchanged when a deployment chooses to gate reads, but absence of a token is not +//! an error on a read path. Authority only ever *narrows* on control. (The Cloud's +//! account gate in front of the identical read contract is a later, separate concern.) +//! +//! # Deferred (noted, not built) +//! +//! - **Persistence.** The [`TokenStore`] is in-memory: tokens live for the process and a +//! restart invalidates every session (the RD re-issues). Durable sessions are a later +//! concern when the Director gains a config/identity store. +//! - **Real signing.** The join-token is an opaque random string, not yet an +//! HMAC/JWT-signed capability — it is validated by store lookup like the bearer token. +//! Self-validating signed join-tokens (so an offline relay can verify one without the +//! issuing store) land with the Cloud/broadcast work; the wire surface (a token string +//! on the subscribe) does not change when it does. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use axum::extract::FromRequestParts; +use axum::http::header::AUTHORIZATION; +use axum::http::request::Parts; + +use crate::error::{ErrorCode, ProtocolError}; + +/// The authority a [`Session`] grants (protocol.html §5). +/// +/// Two levels are all the contract needs today: the privileged **RD** role that may +/// drive control, and a **read-only** role (the join-token's level) that may watch but +/// never control. The distinction is enforced at the one control chokepoint via +/// [`Role::can_control`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// The Race Director: authenticated, control-authorized (§5 "the RD's authenticated + /// role"). Minted by [`TokenStore::issue_rd_token`]. + Rd, + /// A read-only viewer: may read the contract, never control. The level a join-token + /// authenticates (§9.4). Minted by [`TokenStore::issue_join_token`]. + ReadOnly, +} + +impl Role { + /// Whether this role may drive the privileged control path (protocol.html §5). + /// Only [`Role::Rd`] can; [`Role::ReadOnly`] never — the join-token "never grants + /// control" rule (§9.4). + pub fn can_control(self) -> bool { + matches!(self, Role::Rd) + } +} + +/// A server-side session a token resolves to (protocol.html §5, §9.4). +/// +/// The token is opaque; the *session* is where the authority lives, so revoking a token +/// is just dropping its session — no token is self-describing, so none can outlive the +/// server's decision to revoke it. +#[derive(Debug, Clone)] +pub struct Session { + /// The authority this session grants. + pub role: Role, +} + +/// The in-memory token → [`Session`] store (protocol.html §5, §9.4) — the auth authority. +/// +/// Opaque random tokens map to sessions carrying a [`Role`]. Mintable +/// ([`issue_rd_token`](TokenStore::issue_rd_token), +/// [`issue_join_token`](TokenStore::issue_join_token)) and **revocable** +/// ([`revoke`](TokenStore::revoke)) — revocation is instant because the token carries no +/// authority of its own, only a key into this map. Cloning shares the one store (it is an +/// `Arc>`), so every axum handler and the [`AppState`](crate::app::AppState) it +/// rides on consult the same sessions. +/// +/// In-memory by design for v0.4 (persistence deferred — see the module docs). +#[derive(Clone, Default)] +pub struct TokenStore { + sessions: Arc>>, +} + +impl TokenStore { + /// An empty store. + pub fn new() -> Self { + Self::default() + } + + /// Mint a fresh **RD-role** bearer token, register its control-authorized session, and + /// return the opaque token string (protocol.html §5). The RD console presents it as + /// `Authorization: Bearer ` on the control path. + pub fn issue_rd_token(&self) -> String { + self.issue(Role::Rd) + } + + /// Mint a fresh **read-only join-token** and register its read-only session + /// (protocol.html §9.4). QR/URL-friendly (an opaque URL-safe string); it authenticates + /// reads but is rejected on control by [`Role::can_control`]. + pub fn issue_join_token(&self) -> String { + self.issue(Role::ReadOnly) + } + + /// Register a session for `role` under a fresh opaque token and return the token. + fn issue(&self, role: Role) -> String { + let token = random_token(); + self.sessions + .write() + .expect("token store lock poisoned") + .insert(token.clone(), Session { role }); + token + } + + /// **Revoke** a token by dropping its session (protocol.html §9.4): instant and + /// total — the token authenticates nothing afterwards. Returns whether a session was + /// actually removed (a no-op for an already-unknown token). + pub fn revoke(&self, token: &str) -> bool { + self.sessions + .write() + .expect("token store lock poisoned") + .remove(token) + .is_some() + } + + /// Resolve a token to its [`Session`], or `None` if it is unknown/revoked. + pub fn session(&self, token: &str) -> Option { + self.sessions + .read() + .expect("token store lock poisoned") + .get(token) + .cloned() + } + + /// Authenticate a control caller: a token resolving to a **control-authorized** + /// session ([`Role::can_control`]) succeeds; anything else — `None` token, an unknown + /// or revoked token, or a read-only/join token — is [`ErrorCode::Unauthorized`]. + /// + /// This is the whole control policy in one place; [`ControlAuth`] is the only caller + /// (see [`crate::control_handler`]). + /// + /// [`ControlAuth`]: crate::control_handler::ControlAuth + pub fn authenticate_control(&self, token: Option<&str>) -> Result { + let token = token.ok_or_else(|| { + ProtocolError::new( + ErrorCode::Unauthorized, + "control requires an Authorization: Bearer header", + ) + })?; + match self.session(token) { + Some(session) if session.role.can_control() => Ok(session), + Some(_) => Err(ProtocolError::new( + ErrorCode::Unauthorized, + "this token is read-only and may not drive control", + )), + None => Err(ProtocolError::new( + ErrorCode::Unauthorized, + "unknown or revoked control token", + )), + } + } + + /// Authenticate a **read** caller (protocol.html §5): reads are open on the LAN, so a + /// missing token is allowed (`Ok(None)`); a *present* token must be valid (an + /// unknown/revoked token is [`ErrorCode::Unauthorized`] rather than silently treated as + /// anonymous). A valid token of either role authenticates a read. The join-token path + /// uses this. + pub fn authenticate_read(&self, token: Option<&str>) -> Result, 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() { + let store = TokenStore::new(); + 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. + let err = store.authenticate_control(Some(&token)).unwrap_err(); + assert_eq!(err.code, ErrorCode::Unauthorized); + } + + #[test] + fn no_token_is_rejected_on_control_but_allowed_on_read() { + let store = TokenStore::new(); + assert_eq!( + store.authenticate_control(None).unwrap_err().code, + ErrorCode::Unauthorized + ); + assert!(store.authenticate_read(None).unwrap().is_none()); + } + + #[test] + fn revoked_token_stops_working() { + let store = TokenStore::new(); + let token = store.issue_rd_token(); + assert!(store.authenticate_control(Some(&token)).is_ok()); + assert!(store.revoke(&token)); + assert_eq!( + store.authenticate_control(Some(&token)).unwrap_err().code, + ErrorCode::Unauthorized + ); + // A second revoke is a harmless no-op. + assert!(!store.revoke(&token)); + } + + #[test] + fn unknown_token_is_unauthorized_on_both_paths() { + let store = TokenStore::new(); + 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/control_handler.rs b/crates/server/src/control_handler.rs index fa3cd00..90bafb3 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -91,19 +91,21 @@ use crate::app::AppState; use crate::control::{Command, CommandAck}; use crate::error::{ErrorCode, ProtocolError}; -/// The **auth seam** for the privileged control path (protocol.html §5), left for #44. +/// 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. Today its -/// extraction is infallible — it admits every caller, exactly as the read paths do -/// pre-#44 — so the control path is reachable for this issue's write-path work without -/// auth being implemented here. +/// 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. /// -/// #44 makes this the single chokepoint: it replaces the permissive -/// [`from_request_parts`](FromRequestParts::from_request_parts) below with a real RD-role -/// check (read the bearer token / session, verify the control role, reject an unprivileged -/// caller with [`ErrorCode::Unauthorized`]). Because every control route already lists -/// `ControlAuth` as its first extractor, that change gates `POST /control` and the -/// `GET /control` upgrade at once **without editing a single handler body**. +/// 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 @@ -111,16 +113,17 @@ pub struct ControlAuth { _private: (), } -impl FromRequestParts for ControlAuth -where - S: Send + Sync, -{ +impl FromRequestParts for ControlAuth { type Rejection = ProtocolError; - async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { - // #44: read the RD credential from `_parts` (Authorization header / session) and - // return `Err(ProtocolError::new(ErrorCode::Unauthorized, …))` for an unprivileged - // caller. For now control is open, matching the unauthenticated read paths. + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> 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. + let token = crate::auth::bearer_token(parts); + state.tokens().authenticate_control(token.as_deref())?; Ok(ControlAuth { _private: () }) } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 1b6a6f3..9b3795e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -57,6 +57,7 @@ #![forbid(unsafe_code)] pub mod app; +pub mod auth; pub mod control; pub mod control_handler; pub mod error; @@ -96,6 +97,24 @@ impl ContractVersion { /// 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. @@ -130,6 +149,37 @@ pub struct ServerHello { 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::*; @@ -154,6 +204,37 @@ mod tests { 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 { diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs index 3ada256..33cdb09 100644 --- a/crates/server/src/scope.rs +++ b/crates/server/src/scope.rs @@ -93,6 +93,14 @@ pub enum Scope { /// cursor, fall back to re-snapshot"). If the gap is too old to replay the server /// answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the /// client re-snapshots. +/// The subscribe frame doubles as the **connect message** for the WS read path +/// (protocol.html §7): it carries the client's [`ContractVersion`](crate::ContractVersion) +/// (`contract_version`) so the server can negotiate the contract band before streaming, +/// and an optional read **token** (`token`) — a bearer or read-only join-token — so a +/// gated deployment can authenticate the read on the same frame. Both are optional and +/// default-absent so an existing fresh subscribe (just a `scope`) is unchanged on the +/// wire; a missing `contract_version` is treated as this build's [`CONTRACT_VERSION`] +/// (the legacy/unspecified client), and a missing `token` is an anonymous LAN read. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub struct SubscribeRequest { @@ -102,6 +110,20 @@ pub struct SubscribeRequest { #[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)] @@ -141,12 +163,15 @@ mod tests { 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` entirely (skip_serializing_if). + // 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"), - "fresh subscribe omits cursor: {json}" + !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); @@ -156,6 +181,8 @@ mod tests { 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(); diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs index 514646f..a7f5733 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -211,7 +211,8 @@ pub async fn stream_handler(ws: WebSocketUpgrade, State(state): State) /// 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. + // 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) => { @@ -220,6 +221,28 @@ async fn run_stream(mut socket: WebSocket, state: AppState) { } }; + // 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". @@ -419,9 +442,19 @@ async fn send_message(socket: &mut WebSocket, message: &StreamMessage) -> Result .map_err(|_| ()) } -/// Close the socket carrying a [`ProtocolError`] in the close frame body (best-effort). +/// 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 reason = serde_json::to_string(&err).unwrap_or_else(|_| err.message.clone()); + 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, diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index dfde0ec..1daf3eb 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -24,34 +24,49 @@ use gridfpv_server::snapshot::{HeatPhase, ProjectionBody}; use gridfpv_server::stream::{Change, StreamMessage}; use gridfpv_storage::InMemoryLog; use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::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 `http://…` address and the -/// server task handle (dropped at test end, aborting the task). -async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { +/// 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(state: AppState) -> (String, String, tokio::task::JoinHandle<()>) { + let rd_token = state.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(state); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (format!("{addr}"), handle) + (format!("{addr}"), rd_token, handle) } fn heat() -> HeatId { HeatId("q-1".into()) } -/// POST one command to `http://{addr}/control` and return the ack. -async fn post_command(addr: &str, command: &Command) -> CommandAck { +/// 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 /control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + {auth}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -59,16 +74,32 @@ async fn post_command(addr: &str, command: &Command) -> CommandAck { stream.write_all(request.as_bytes()).await.unwrap(); let mut response = String::new(); stream.read_to_string(&mut response).await.unwrap(); - let json = response + 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) - .expect("response has a body"); - serde_json::from_str(json).expect("body is a CommandAck") + .and_then(|json| serde_json::from_str(json).ok()); + (status, ack) } -/// Connect the control WS at `ws://{addr}/control`. -async fn control_ws(addr: &str) -> Ws { - let (ws, _) = connect_async(format!("ws://{addr}/control")).await.unwrap(); +/// 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}/control").parse().unwrap(); + let request = + ClientRequestBuilder::new(uri).with_header("Authorization", format!("Bearer {token}")); + let (ws, _) = connect_async(request).await.unwrap(); ws } @@ -91,7 +122,12 @@ async fn send_command(ws: &mut Ws, command: &Command) -> CommandAck { /// Subscribe a `/stream` reader and await the next live-state phase. async fn subscribe_stream(addr: &str, scope: Scope) -> Ws { let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap(); - let request = SubscribeRequest { scope, from: None }; + let request = SubscribeRequest { + scope, + from: None, + contract_version: None, + token: None, + }; ws.send(Message::text(serde_json::to_string(&request).unwrap())) .await .unwrap(); @@ -124,7 +160,7 @@ async fn next_phase(ws: &mut Ws) -> HeatPhase { #[tokio::test] async fn post_command_drives_heat_loop_and_reaches_stream() { let state = AppState::new(InMemoryLog::default()); - let (addr, _server) = serve(state.clone()).await; + let (addr, rd, _server) = serve(state.clone()).await; // Schedule the heat, then subscribe so the subscriber starts from the scheduled state. let ack = post_command( @@ -133,6 +169,7 @@ async fn post_command_drives_heat_loop_and_reaches_stream() { heat: heat(), lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], }, + &rd, ) .await; assert!(ack.ok, "schedule should ack ok: {ack:?}"); @@ -143,12 +180,12 @@ async fn post_command_drives_heat_loop_and_reaches_stream() { assert_eq!(next_phase(&mut stream).await, HeatPhase::Scheduled); // Stage via the control path; the subscriber observes the resulting transition. - let ack = post_command(&addr, &Command::Stage { heat: heat() }).await; + 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); // Arm, again read back off the stream. - let ack = post_command(&addr, &Command::Arm { heat: heat() }).await; + let ack = post_command(&addr, &Command::Arm { heat: heat() }, &rd).await; assert!(ack.ok, "arm should ack ok: {ack:?}"); assert_eq!(next_phase(&mut stream).await, HeatPhase::Armed); } @@ -159,9 +196,9 @@ async fn post_command_drives_heat_loop_and_reaches_stream() { #[tokio::test] async fn control_ws_acks_each_command_and_rejects_illegal() { let state = AppState::new(InMemoryLog::default()); - let (addr, _server) = serve(state.clone()).await; + let (addr, rd, _server) = serve(state.clone()).await; - let mut control = control_ws(&addr).await; + let mut control = control_ws(&addr, &rd).await; // Schedule then subscribe. let ack = send_command( @@ -207,8 +244,8 @@ async fn control_ws_acks_each_command_and_rejects_illegal() { #[tokio::test] async fn control_ws_survives_a_malformed_frame() { let state = AppState::new(InMemoryLog::default()); - let (addr, _server) = serve(state.clone()).await; - let mut control = control_ws(&addr).await; + let (addr, rd, _server) = serve(state.clone()).await; + let mut control = control_ws(&addr, &rd).await; control .send(Message::text("{not a command}")) @@ -237,3 +274,170 @@ async fn control_ws_survives_a_malformed_frame() { .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 state = AppState::new(InMemoryLog::default()); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(state.clone()).await; + let cmd = Command::ScheduleHeat { + heat: heat(), + lineup: vec![], + }; + + // 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. + assert!(state.tokens().revoke(&rd)); + assert_eq!(post_status(&addr, &cmd, Some(&rd)).await, 401); +} + +/// `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 state = AppState::new(InMemoryLog::default()); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(state.clone()).await; + + // No token: the upgrade is refused. + let no_token = connect_async(format!("ws://{addr}/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}/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![], + }, + ) + .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 state = AppState::new(InMemoryLog::default()); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(state.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())], + }, + &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}/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 state = AppState::new(InMemoryLog::default()); + let (addr, rd, _server) = serve(state.clone()).await; + let ack = post_command( + &addr, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![], + }, + &rd, + ) + .await; + assert!(ack.ok); + + // Too-new version → the stream closes with a VersionMismatch refresh signal. + let (mut ws, _) = connect_async(format!("ws://{addr}/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}/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/ws_stream.rs b/crates/server/tests/ws_stream.rs index 5165670..fd488fa 100644 --- a/crates/server/tests/ws_stream.rs +++ b/crates/server/tests/ws_stream.rs @@ -108,6 +108,8 @@ async fn streams_ordered_envelopes_from_a_fresh_subscribe() { &SubscribeRequest { scope: event_scope(), from: None, + contract_version: None, + token: None, }, ) .await; @@ -170,6 +172,8 @@ async fn resume_from_a_mid_cursor_replays_only_the_tail() { &SubscribeRequest { scope: event_scope(), from: Some(Cursor::new(2)), + contract_version: None, + token: None, }, ) .await; @@ -207,6 +211,8 @@ async fn too_old_cursor_requires_re_snapshot() { &SubscribeRequest { scope: event_scope(), from: Some(Cursor::new(1)), + contract_version: None, + token: None, }, ) .await; @@ -231,6 +237,8 @@ async fn unchanged_fold_emits_no_envelope() { &SubscribeRequest { scope: event_scope(), from: None, + contract_version: None, + token: None, }, ) .await; From 352b2574a4e1fdeb964ed22ec157c25920b28410 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:30:31 +0000 Subject: [PATCH 010/362] =?UTF-8?q?Build=20the=20GridFPV=20RD=20console=20?= =?UTF-8?q?(#51=E2=80=93#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the rd-console app from a components playground into a real, navigable, keyboard-friendly Director console wrapping the `.gridfpv-dense` token context. It is the one privileged surface: reads flow through `@gridfpv/protocol-client` (snapshot + WS stream), writes through a new control client over the same protocol. Every screen composes the shared `@gridfpv/components` widgets and builds typed `Command`s verbatim from `@gridfpv/types`. Screens: - Shell + auth (#51): bearer-token login (token in memory + sessionStorage, never disk), sidebar across the five screens with Alt+digit shortcuts, live connection-status readout. `Session` (runes) owns both seams. - Setup wizard (#52): stepped event -> class(es) -> track -> format/win-condition form producing a typed `EventConfig`. Held in app state since the contract has no event/class/track setup command yet; the one wire action it can drive is `ScheduleHeat` (gap documented in lib/setup.ts). - Registration (#53): lists seen competitor refs from the live snapshot/stream; binds each to a pilot, emitting `Register`. - Live race control (#54): current heat via HeatSheet + RaceClock + a live Leaderboard from the `LiveRaceState` stream; one button per heat-loop transition (Stage/Arm/Start/Finish/Score/Advance + Abort/Restart/Discard) calling sendCommand; illegal actions disabled by live `phase`; destructive off-ramps confirm; `CommandAck` errors surfaced via the shared ProtocolError. - Marshaling (#55): VoidDetection / InsertLap / AdjustLap / VoidHeat / ApplyPenalty commands; the stream re-folds and pushes a fresh result. - Results (#56): Leaderboard + StandingsTable + BracketTree (derived from an EventOutcome) with a JSON-download export. Control path: `createControlClient(baseUrl, token?)` -> `sendCommand(cmd: Command): Promise` POSTing JSON to `/control` with a bearer token. The exact endpoint/method/transport (POST `/control` vs a control WS) MUST be reconciled with the server control path (#45) on merge — flagged in a code comment and the report. `Command`/`CommandAck` types are used verbatim. Tests (vitest + @testing-library/svelte): phase->enabled-commands mapping, control-client transport, marshaling/registration/setup/results command builders, the session, and component tests for each screen against a mock ProtocolClient + mock sendCommand. 51 console tests; full frontend build/check/ lint/test all green. Part of #51–#56. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/package.json | 9 +- frontend/apps/rd-console/src/App.svelte | 343 +++++++++++------- .../rd-console/src/lib/ConfirmButton.svelte | 114 ++++++ .../rd-console/src/lib/ErrorBanner.svelte | 50 +++ frontend/apps/rd-console/src/lib/control.ts | 141 +++++++ .../apps/rd-console/src/lib/marshaling.ts | 70 ++++ .../apps/rd-console/src/lib/registration.ts | 24 ++ frontend/apps/rd-console/src/lib/results.ts | 96 +++++ .../apps/rd-console/src/lib/session.svelte.ts | 173 +++++++++ frontend/apps/rd-console/src/lib/setup.ts | 119 ++++++ .../apps/rd-console/src/lib/transitions.ts | 170 +++++++++ .../src/screens/LiveRaceControl.svelte | 188 ++++++++++ .../apps/rd-console/src/screens/Login.svelte | 100 +++++ .../rd-console/src/screens/Marshaling.svelte | 253 +++++++++++++ .../src/screens/Registration.svelte | 155 ++++++++ .../rd-console/src/screens/Results.svelte | 99 +++++ .../rd-console/src/screens/SetupWizard.svelte | 315 ++++++++++++++++ .../rd-console/tests/LiveRaceControl.test.ts | 61 ++++ .../rd-console/tests/MarshalingScreen.test.ts | 50 +++ .../tests/RegistrationScreen.test.ts | 36 ++ .../rd-console/tests/ResultsScreen.test.ts | 25 ++ .../apps/rd-console/tests/SetupWizard.test.ts | 35 ++ .../apps/rd-console/tests/control.test.ts | 80 ++++ frontend/apps/rd-console/tests/fixtures.ts | 139 +++++++ .../apps/rd-console/tests/marshaling.test.ts | 51 +++ .../rd-console/tests/registration.test.ts | 10 + .../apps/rd-console/tests/results.test.ts | 29 ++ .../rd-console/tests/session.svelte.test.ts | 98 +++++ frontend/apps/rd-console/tests/setup.test.ts | 47 +++ frontend/apps/rd-console/tests/support.ts | 51 +++ .../apps/rd-console/tests/transitions.test.ts | 92 +++++ frontend/apps/rd-console/tsconfig.test.json | 10 + frontend/apps/rd-console/vitest.config.ts | 42 +++ frontend/apps/rd-console/vitest.setup.ts | 3 + frontend/package-lock.json | 6 +- 35 files changed, 3153 insertions(+), 131 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/ConfirmButton.svelte create mode 100644 frontend/apps/rd-console/src/lib/ErrorBanner.svelte create mode 100644 frontend/apps/rd-console/src/lib/control.ts create mode 100644 frontend/apps/rd-console/src/lib/marshaling.ts create mode 100644 frontend/apps/rd-console/src/lib/registration.ts create mode 100644 frontend/apps/rd-console/src/lib/results.ts create mode 100644 frontend/apps/rd-console/src/lib/session.svelte.ts create mode 100644 frontend/apps/rd-console/src/lib/setup.ts create mode 100644 frontend/apps/rd-console/src/lib/transitions.ts create mode 100644 frontend/apps/rd-console/src/screens/LiveRaceControl.svelte create mode 100644 frontend/apps/rd-console/src/screens/Login.svelte create mode 100644 frontend/apps/rd-console/src/screens/Marshaling.svelte create mode 100644 frontend/apps/rd-console/src/screens/Registration.svelte create mode 100644 frontend/apps/rd-console/src/screens/Results.svelte create mode 100644 frontend/apps/rd-console/src/screens/SetupWizard.svelte create mode 100644 frontend/apps/rd-console/tests/LiveRaceControl.test.ts create mode 100644 frontend/apps/rd-console/tests/MarshalingScreen.test.ts create mode 100644 frontend/apps/rd-console/tests/RegistrationScreen.test.ts create mode 100644 frontend/apps/rd-console/tests/ResultsScreen.test.ts create mode 100644 frontend/apps/rd-console/tests/SetupWizard.test.ts create mode 100644 frontend/apps/rd-console/tests/control.test.ts create mode 100644 frontend/apps/rd-console/tests/fixtures.ts create mode 100644 frontend/apps/rd-console/tests/marshaling.test.ts create mode 100644 frontend/apps/rd-console/tests/registration.test.ts create mode 100644 frontend/apps/rd-console/tests/results.test.ts create mode 100644 frontend/apps/rd-console/tests/session.svelte.test.ts create mode 100644 frontend/apps/rd-console/tests/setup.test.ts create mode 100644 frontend/apps/rd-console/tests/support.ts create mode 100644 frontend/apps/rd-console/tests/transitions.test.ts create mode 100644 frontend/apps/rd-console/tsconfig.test.json create mode 100644 frontend/apps/rd-console/vitest.config.ts create mode 100644 frontend/apps/rd-console/vitest.setup.ts diff --git a/frontend/apps/rd-console/package.json b/frontend/apps/rd-console/package.json index ed294b8..cdbc378 100644 --- a/frontend/apps/rd-console/package.json +++ b/frontend/apps/rd-console/package.json @@ -9,7 +9,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json && svelte-check --tsconfig ./tsconfig.test.json", + "test": "vitest run" }, "dependencies": { "@gridfpv/components": "*", @@ -18,9 +19,13 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.6", + "jsdom": "^25.0.1", "svelte": "^5.16.0", "svelte-check": "^4.1.1", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^2.1.8" } } diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 9a9bd75..ef23aa8 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -1,152 +1,239 @@ - -
-

GridFPV — RD console

-

- Component library playground. Connected to {client.baseUrl} (stub). -

- -
-

Race clock

-
- - -
-
+ } + } -
-

Pilot card

- -
+ // Note: the wizard's config is held locally (no event/class/track setup command on + // the contract yet — see lib/setup.ts); the one wire-driving setup action available + // today is `scheduleHeatCommand` (ScheduleHeat), emitted once a lineup is known. -
-

Heat sheet (live)

- -
+ function statusTone(status: string): string { + if (status === 'live') return 'ok'; + if (status === 'reconnecting' || status === 'closed') return 'warn'; + if (status === 'idle') return 'idle'; + return 'pending'; + } + -
-

Leaderboard

- -
+ -
-

Standings

- -
+{#if !session.authenticated} +
+ +
+{:else} +
+ -
-

Bracket

- -
-
+
+ {#if active === 'setup'} + + {:else if active === 'registration'} + + {:else if active === 'live'} + + {:else if active === 'marshaling'} + + {:else if active === 'results'} + + {/if} +
+ +{/if} diff --git a/frontend/apps/rd-console/src/lib/ConfirmButton.svelte b/frontend/apps/rd-console/src/lib/ConfirmButton.svelte new file mode 100644 index 0000000..cef9b58 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/ConfirmButton.svelte @@ -0,0 +1,114 @@ + + + + {#if armed} + + + {:else} + + {/if} + + + diff --git a/frontend/apps/rd-console/src/lib/ErrorBanner.svelte b/frontend/apps/rd-console/src/lib/ErrorBanner.svelte new file mode 100644 index 0000000..d6bd483 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/ErrorBanner.svelte @@ -0,0 +1,50 @@ + + +{#if error} + +{/if} + + diff --git a/frontend/apps/rd-console/src/lib/control.ts b/frontend/apps/rd-console/src/lib/control.ts new file mode 100644 index 0000000..e1c5676 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/control.ts @@ -0,0 +1,141 @@ +/** + * The privileged control path — the one place the RD console writes (clients.html + * §1: the RD console is "the ONLY surface with the privileged control path"). + * + * Commands go up, acknowledgements come down (protocol.html §5): every privileged + * action is a {@link Command} POSTed to the server, which replies with a single + * {@link CommandAck} (`ok` + an optional shared {@link ProtocolError}). The console's + * *reads* still flow through `@gridfpv/protocol-client` (snapshot + WS stream); the + * resulting projection state comes back on that read stream, never in the ack. + * + * ── CONTRACT TO RECONCILE WITH #45 (the server control path) ──────────────────── + * The control endpoint/transport is being built in parallel (#45). This client + * assumes the simplest shape the contract implies: + * + * • transport: HTTP `POST {baseUrl}/control` + * • request body: a JSON-serialized `Command` (the externally-tagged enum verbatim, + * e.g. `{"Stage":{"heat":"heat-1"}}`) + * • auth: `Authorization: Bearer ` (protocol.html §9.4), same token the + * read client uses + * • response body: a JSON-serialized `CommandAck` (`{ok, error?}`) + * • a non-2xx HTTP status is normalized into a failed `CommandAck` whose `error` + * is the server's `ProtocolError` body when present, else a synthetic one. + * + * The exact path (`/control` vs `/command`), method, and whether commands ride a + * dedicated control WS instead of POST MUST be reconciled with #45 on merge. The + * lead will align them. The `Command`/`CommandAck` *types* are taken verbatim from + * `@gridfpv/types`, so only the transport seam below moves — every screen builds a + * typed `Command` and calls `sendCommand`, agnostic to how it ships. + * + * `Command` carries `bigint` fields (`SourceTime`, `LogRef`, `Penalty.TimeAdded`). + * `JSON.stringify` cannot serialize a `bigint`, so {@link stringifyCommand} renders + * them as JSON numbers — matching serde's u64/i64 default and the read client's + * `stringifyWire`. (A deployment needing full-u64 precision would serialize as + * strings on both sides; this mirrors that decision when #45 makes it.) + */ + +import type { Command, CommandAck, ProtocolError } from '@gridfpv/types'; + +/** Minimal `fetch` surface this client needs (injectable for tests / Node). */ +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +/** A control client: turns a typed {@link Command} into a {@link CommandAck}. */ +export interface ControlClient { + /** The base URL commands are POSTed to. */ + readonly baseUrl: string; + /** + * Send a privileged command and resolve with its acknowledgement. Never rejects + * for a server-side / transport failure — those resolve as a failed `CommandAck` + * (`ok: false`) so callers branch on one shape. Only a programmer error throws. + */ + sendCommand(command: Command): Promise; +} + +const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s); + +/** + * `JSON.stringify` for a `Command`, rendering `bigint` fields as JSON numbers + * (serde's default for u64/i64). See the module note on full-u64 precision. + */ +export function stringifyCommand(command: Command): string { + return JSON.stringify(command, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); +} + +const isProtocolError = (v: unknown): v is ProtocolError => + typeof v === 'object' && + v !== null && + 'code' in v && + 'message' in v && + typeof (v as ProtocolError).message === 'string'; + +const isCommandAck = (v: unknown): v is CommandAck => + typeof v === 'object' && v !== null && 'ok' in v && typeof (v as CommandAck).ok === 'boolean'; + +/** Build a failed ack carrying a synthetic protocol error. */ +function failedAck(code: ProtocolError['code'], message: string): CommandAck { + return { ok: false, error: { code, message } }; +} + +/** Options for {@link createControlClient}. */ +export interface ControlClientOptions { + /** Inject a `fetch` (defaults to the global). Used by tests and Node. */ + fetch?: FetchLike; +} + +/** + * Create a {@link ControlClient} bound to a base URL and optional bearer token. + * + * @param baseUrl Director protocol server base URL (same one the read client uses). + * @param token Optional bearer token, sent as `Authorization: Bearer `. + */ +export function createControlClient( + baseUrl: string, + token?: string, + options: ControlClientOptions = {} +): ControlClient { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const base = trimSlash(baseUrl); + + return { + baseUrl: base, + async sendCommand(command: Command): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; + + let resp: Response; + try { + resp = await fetchImpl(`${base}/control`, { + method: 'POST', + headers, + body: stringifyCommand(command) + }); + } catch (e) { + return failedAck('Internal', `control request failed: ${String(e)}`); + } + + // Parse the body once; reuse it whether the status is ok or not. + let data: unknown; + try { + data = await resp.json(); + } catch { + data = undefined; + } + + if (resp.ok) { + if (isCommandAck(data)) return data; + // A 2xx with a body that is not a CommandAck is a contract violation; treat + // it as success only if the server clearly signals it, else flag it. + return failedAck('Internal', `malformed CommandAck (HTTP ${resp.status})`); + } + + // Non-2xx: prefer the server's ProtocolError body, else a CommandAck body, + // else synthesize one from the HTTP status (protocol.html §9.8). + if (isCommandAck(data)) return data; + if (isProtocolError(data)) return { ok: false, error: data }; + return failedAck('Internal', `control HTTP ${resp.status}`); + } + }; +} diff --git a/frontend/apps/rd-console/src/lib/marshaling.ts b/frontend/apps/rd-console/src/lib/marshaling.ts new file mode 100644 index 0000000..37dd7df --- /dev/null +++ b/frontend/apps/rd-console/src/lib/marshaling.ts @@ -0,0 +1,70 @@ +/** + * Marshaling adjudication command builders (#55). + * + * Marshaling never mutates state directly: each correction *appends* the matching + * adjudication event, which the projection re-folds into a fresh result (clients.html + * §1, architecture.html §3). The console emits the corresponding `Command`; the + * corrected projection arrives back on the read stream as a fresh value (the live + * client folds it and pushes a new state — the screen re-renders, nothing local to + * reconcile). + * + * `Command`'s five marshaling variants are pure functions of their inputs here, so the + * screen builds them declaratively and the tests assert the exact wire shape. Targets + * are `LogRef` (a u64 log offset, `bigint`); times are `SourceTime` (µs, `bigint`). + */ + +import type { + AdapterId, + Command, + CompetitorRef, + HeatId, + LogRef, + Penalty, + SourceTime +} from '@gridfpv/types'; + +/** Void a single logged detection (a bad pass, a phantom lap) by its log offset. */ +export function voidDetectionCommand(target: LogRef): Command { + return { VoidDetection: { target } }; +} + +/** Insert a missed crossing for a competitor at a source-clock time. */ +export function insertLapCommand( + adapter: AdapterId, + competitor: CompetitorRef, + at: SourceTime +): Command { + return { InsertLap: { adapter, competitor, at } }; +} + +/** Re-time an existing logged pass (identified by its log offset) to a new time. */ +export function adjustLapCommand(target: LogRef, at: SourceTime): Command { + return { AdjustLap: { target, at } }; +} + +/** Void a whole heat (it should never have counted). */ +export function voidHeatCommand(heat: HeatId): Command { + return { VoidHeat: { heat } }; +} + +/** Apply a penalty (disqualification or added time) to a competitor in a heat. */ +export function applyPenaltyCommand( + heat: HeatId, + competitor: CompetitorRef, + penalty: Penalty +): Command { + return { ApplyPenalty: { heat, competitor, penalty } }; +} + +/** Build a `TimeAdded` penalty from a whole-second amount (the console's input unit). */ +export function timeAddedPenalty(seconds: number): Penalty { + return { TimeAdded: { micros: BigInt(Math.round(seconds * 1_000_000)) } }; +} + +/** The disqualification penalty. */ +export const DISQUALIFY: Penalty = 'Disqualify'; + +/** Convert the console's whole-second time input to `SourceTime` microseconds. */ +export function secondsToSourceTime(seconds: number): SourceTime { + return BigInt(Math.round(seconds * 1_000_000)); +} diff --git a/frontend/apps/rd-console/src/lib/registration.ts b/frontend/apps/rd-console/src/lib/registration.ts new file mode 100644 index 0000000..6e2112c --- /dev/null +++ b/frontend/apps/rd-console/src/lib/registration.ts @@ -0,0 +1,24 @@ +/** + * Registration command builder (#53). + * + * Registration binds a *source-local* competitor handle (a node seat, a sim name — + * `CompetitorRef`, only meaningful relative to its `AdapterId`) to an event-scoped + * `PilotId`. The adapter never does this binding itself (architecture.html §9); the + * RD does it here via the `Register` command. + * + * The competitors the RD can bind are the ones the timing source has *seen* — surfaced + * by the live projection (`LiveRaceState.active_pilots`, which are `CompetitorRef`s). + * This module is the pure builder; the screen lists seen refs and emits one `Register` + * per binding. + */ + +import type { AdapterId, Command, CompetitorRef, PilotId } from '@gridfpv/types'; + +/** Bind a source-local competitor (on `adapter`) to an event-scoped `pilot`. */ +export function registerCommand( + adapter: AdapterId, + competitor: CompetitorRef, + pilot: PilotId +): Command { + return { Register: { adapter, competitor, pilot } }; +} diff --git a/frontend/apps/rd-console/src/lib/results.ts b/frontend/apps/rd-console/src/lib/results.ts new file mode 100644 index 0000000..f7efa69 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/results.ts @@ -0,0 +1,96 @@ +/** + * Results-screen derivations (#56). + * + * The results screen renders heat results (`Leaderboard`), rankings/standings + * (`StandingsTable`), and a bracket (`BracketTree`) from typed projection data. Two of + * those map straight to a wire type; the bracket does not (the wire has no bracket + * projection — see `@gridfpv/components`'s `bracket.ts`), so we derive the component's + * `Bracket` view-model from an `EventOutcome`'s completed bracket heats. This is the + * caller-side derivation that `bracket.ts` describes. + * + * Export is a JSON download of the typed projection — good enough for v0.4 (#56) and + * lossless, since it is the exact wire value. + */ + +import type { Bracket, BracketMatch, BracketRound } from '@gridfpv/components'; +import type { + CompetitorRef, + CompletedHeat, + EventOutcome, + HeatResult, + Placement +} from '@gridfpv/types'; + +/** The top (winning) placement's competitor ref, if the heat has any places. */ +function winnerOf(result: HeatResult): CompetitorRef | undefined { + const top: Placement | undefined = result.places[0]; + return top?.competitor.competitor; +} + +/** + * Derive a `Bracket` view-model from an `EventOutcome`'s completed bracket heats. + * + * Without a named round structure on the wire we lay each completed bracket heat out + * as its own match and group sequential heats into rounds by halving: the last heat is + * the final, the two before it the semis, and so on. This mirrors a single-elim shape + * well enough for display; when the server grows a real bracket projection this is + * replaced wholesale. + */ +export function bracketFromOutcome(outcome: EventOutcome): Bracket { + const heats = outcome.bracket_heats; + if (heats.length === 0) return { rounds: [] }; + + const matches: BracketMatch[] = heats.map((h: CompletedHeat) => { + const winner = winnerOf(h.result); + return { + heat: h.heat, + slots: h.result.places.map((p) => ({ + competitor: p.competitor.competitor, + winner: p.competitor.competitor === winner + })) + }; + }); + + // Group from the end: final (1), then doubling round sizes backward (…, 4, 2, 1). + const rounds: BracketRound[] = []; + let remaining = matches.slice(); + let size = 1; + while (remaining.length > 0) { + const take = Math.min(size, remaining.length); + const roundMatches = remaining.slice(remaining.length - take); + remaining = remaining.slice(0, remaining.length - take); + rounds.unshift({ name: roundNameFor(size, roundMatches.length), matches: roundMatches }); + size *= 2; + } + return { rounds }; +} + +function roundNameFor(size: number, count: number): string { + if (size === 1 && count === 1) return 'Final'; + if (size === 2) return 'Semifinals'; + if (size === 4) return 'Quarterfinals'; + return `Round of ${size * 2}`; +} + +/** Serialize a value to pretty JSON, rendering `bigint` as a JSON number. */ +export function toExportJson(value: unknown): string { + return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? Number(v) : v), 2); +} + +/** + * Trigger a browser download of `json` as `filename`. No-op outside a DOM (tests call + * `toExportJson` directly). Returns whether a download was initiated. + */ +export function downloadJson(filename: string, json: string): boolean { + if (typeof document === 'undefined' || typeof URL?.createObjectURL !== 'function') return false; + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return true; +} diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts new file mode 100644 index 0000000..8446f62 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -0,0 +1,173 @@ +/** + * The console session: auth + the two connection seams (#51). + * + * The RD authenticates with a bearer token (protocol.html §9.4). Per the brief the + * token lives in memory, mirrored to `sessionStorage` so a tab reload inside the same + * session keeps the RD signed in but it never persists to disk. That one token feeds + * both seams: + * + * • reads — `@gridfpv/protocol-client`'s `connect({ baseUrl, scope, token })` + * (snapshot + WS stream; the console's whole live view). + * • writes — `createControlClient(baseUrl, token)` (the privileged control path). + * + * Everything reactive is Svelte 5 runes so screens read `session.connectionStatus`, + * `session.liveState`, etc. directly. The protocol client is framework-agnostic, so + * we bridge its `onState` callback into a `$state` field here. + */ + +import { connect } from '@gridfpv/protocol-client'; +import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; +import { createControlClient } from './control.js'; +import type { ControlClient } from './control.js'; +import type { Command, CommandAck, LiveRaceState, ProjectionBody, Scope } from '@gridfpv/types'; + +const STORAGE_KEY = 'gridfpv.rd.session'; + +interface StoredSession { + baseUrl: string; + token: string; +} + +function loadStored(): StoredSession | null { + try { + const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.baseUrl === 'string' && typeof parsed.token === 'string') { + return { baseUrl: parsed.baseUrl, token: parsed.token }; + } + } catch { + /* ignore malformed storage */ + } + return null; +} + +function persist(s: StoredSession | null): void { + try { + if (s) globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(s)); + else globalThis.sessionStorage?.removeItem(STORAGE_KEY); + } catch { + /* storage unavailable (private mode / SSR) — in-memory still works */ + } +} + +/** Pull the `LiveRaceState` out of a projection body, if that's what it carries. */ +function liveStateOf(body: ProjectionBody | undefined): LiveRaceState | undefined { + if (body && 'LiveRaceState' in body) return body.LiveRaceState; + return undefined; +} + +/** The reactive console session — one instance, shared across screens. */ +export class Session { + /** Whether the RD has signed in (a token is held). */ + authenticated = $state(false); + /** The base URL the RD connected to. */ + baseUrl = $state(''); + /** The live read-client's lifecycle status (connecting → live → …). */ + connectionStatus = $state('idle'); + /** The latest full protocol state (body + cursor + status + error). */ + protocolState = $state(undefined); + /** The current `LiveRaceState`, when the live scope is connected. */ + liveState = $state(undefined); + /** The last control-path error surfaced to the RD (cleared on the next send). */ + lastCommandError = $state(undefined); + + // Non-reactive internals. + #token: string | undefined; + #client: ProtocolClient | undefined; + #control: ControlClient | undefined; + #unsub: (() => void) | undefined; + // Injectable for tests so the session never opens a real socket. + #connectImpl: typeof connect; + #controlFactory: typeof createControlClient; + + constructor(opts?: { + connectImpl?: typeof connect; + controlFactory?: typeof createControlClient; + autoRestore?: boolean; + }) { + this.#connectImpl = opts?.connectImpl ?? connect; + this.#controlFactory = opts?.controlFactory ?? createControlClient; + if (opts?.autoRestore !== false) { + const stored = loadStored(); + if (stored) this.login(stored.baseUrl, stored.token); + } + } + + /** + * Sign in: hold the token, open the control client, and connect the live read + * client to the event scope so the console has a live view from the start. + */ + login(baseUrl: string, token: string, scope?: Scope): void { + this.logout(false); + this.#token = token; + this.baseUrl = baseUrl; + this.authenticated = true; + this.#control = this.#controlFactory(baseUrl, token); + persist({ baseUrl, token }); + + // Default to the whole event; a real event id is filled by the setup wizard, but + // the console connects optimistically so connection status is visible at login. + const liveScope: Scope = scope ?? { Event: { event: 'event' } }; + this.connectionStatus = 'connecting'; + this.#client = this.#connectImpl({ baseUrl, scope: liveScope, token }); + this.#unsub = this.#client.onState((state) => { + this.protocolState = state; + this.connectionStatus = state.status; + this.liveState = liveStateOf(state.body); + }); + } + + /** Re-scope the live read client (e.g. once the event id is known). */ + resubscribe(scope: Scope): void { + if (!this.authenticated || !this.#token) return; + this.#unsub?.(); + this.#client?.close(); + this.connectionStatus = 'connecting'; + this.#client = this.#connectImpl({ baseUrl: this.baseUrl, scope, token: this.#token }); + this.#unsub = this.#client.onState((state) => { + this.protocolState = state; + this.connectionStatus = state.status; + this.liveState = liveStateOf(state.body); + }); + } + + /** Sign out and tear down both seams. */ + logout(clearStorage = true): void { + this.#unsub?.(); + this.#unsub = undefined; + this.#client?.close(); + this.#client = undefined; + this.#control = undefined; + this.#token = undefined; + this.authenticated = false; + this.connectionStatus = 'idle'; + this.protocolState = undefined; + this.liveState = undefined; + this.lastCommandError = undefined; + if (clearStorage) persist(null); + } + + /** + * Send a privileged command through the control client, recording any error for + * the UI. Returns the raw `CommandAck` so callers can branch on success too. + */ + async send(command: Command): Promise { + if (!this.#control) { + const ack: CommandAck = { + ok: false, + error: { code: 'Unauthorized', message: 'Not signed in.' } + }; + this.lastCommandError = ack.error; + return ack; + } + const ack = await this.#control.sendCommand(command); + this.lastCommandError = ack.ok ? undefined : ack.error; + return ack; + } + + /** Clear the last surfaced command error (e.g. when the RD dismisses it). */ + clearCommandError(): void { + this.lastCommandError = undefined; + } +} diff --git a/frontend/apps/rd-console/src/lib/setup.ts b/frontend/apps/rd-console/src/lib/setup.ts new file mode 100644 index 0000000..8ce34d2 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/setup.ts @@ -0,0 +1,119 @@ +/** + * Setup-wizard config model (#52). + * + * The wizard walks the RD through event → class(es) → track → format + win condition + * (clients.html §1, roadmap v0.4). It produces a single typed {@link EventConfig}. + * + * ── GAP: no event/class/track setup command on the contract yet ───────────────── + * `Command` (protocol.html §5) today exposes only `ScheduleHeat` and `Register` among + * the setup-shaped variants — there is NO `ConfigureEvent` / `DefineClass` / `SetTrack` + * / `SetFormat` command. So the wizard's event/class/track/format choices are held in + * **app state** (this config object), and the one part it *can* drive on the wire is + * scheduling heats: each planned heat becomes a `ScheduleHeat` command carrying its + * lineup. When #45 (and the server's setup surface) grows configuration commands, the + * wizard emits them here and this config becomes their payload rather than a local hold. + * + * `WinCondition` is taken verbatim from `@gridfpv/types` (it is a real wire type the + * heat/format scoring already uses), so the format step is type-accurate today even + * though the event/class/track steps are local-only. + */ + +import type { + ClassId, + Command, + CompetitorRef, + EventId, + HeatId, + WinCondition +} from '@gridfpv/types'; + +/** The race format a class runs under (roadmap v0.4: timed-qual / single-elim / zippyq). */ +export type RaceFormat = 'timed-qual' | 'single-elim' | 'zippyq'; + +/** Human labels for the formats, for the wizard's format step. */ +export const FORMAT_LABELS: Record = { + 'timed-qual': 'Timed qualifying', + 'single-elim': 'Single elimination', + zippyq: 'ZippyQ (rolling)' +}; + +/** One class within the event being configured. */ +export interface ClassConfig { + /** Event-scoped class id (a stable handle the RD names). */ + id: ClassId; + /** Human class name (e.g. "Open", "Spec"). */ + name: string; + /** The format this class runs. */ + format: RaceFormat; + /** How a heat in this class is won. */ + winCondition: WinCondition; +} + +/** The whole event configuration the wizard produces. */ +export interface EventConfig { + /** Event id (a stable handle the RD names). */ + eventId: EventId; + /** Human event name. */ + eventName: string; + /** Track / venue name (free text until a track model exists on the wire). */ + track: string; + /** The classes configured for the event (at least one). */ + classes: ClassConfig[]; +} + +/** A reasonable default win condition per format, for the wizard's initial state. */ +export function defaultWinCondition(format: RaceFormat): WinCondition { + switch (format) { + case 'timed-qual': + // A 2-minute timed window (µs). + return { Timed: { window_micros: 120_000_000n } }; + case 'single-elim': + // First to 3 laps decides a bracket heat. + return { FirstToLaps: { n: 3 } }; + case 'zippyq': + // ZippyQ ranks on best 3 consecutive laps. + return { BestConsecutive: { n: 3 } }; + } +} + +/** An empty config to seed the wizard. */ +export function emptyConfig(): EventConfig { + return { eventId: '', eventName: '', track: '', classes: [] }; +} + +/** Build a default class config for `format`. */ +export function defaultClass(id: ClassId, name: string, format: RaceFormat): ClassConfig { + return { id, name, format, winCondition: defaultWinCondition(format) }; +} + +/** + * Validate the config for completeness — the wizard's "you can finish" gate. Returns + * the list of human problems (empty ⇒ ready). + */ +export function validateConfig(config: EventConfig): string[] { + const problems: string[] = []; + if (!config.eventName.trim()) problems.push('Event needs a name.'); + if (!config.eventId.trim()) problems.push('Event needs an id.'); + if (!config.track.trim()) problems.push('Track needs a name.'); + if (config.classes.length === 0) problems.push('Add at least one class.'); + config.classes.forEach((c, i) => { + if (!c.id.trim()) problems.push(`Class ${i + 1} needs an id.`); + if (!c.name.trim()) problems.push(`Class ${i + 1} needs a name.`); + }); + return problems; +} + +/** Is the config complete enough to finish the wizard? */ +export function isConfigComplete(config: EventConfig): boolean { + return validateConfig(config).length === 0; +} + +/** + * Build the one setup command the contract supports today: `ScheduleHeat`, creating a + * heat with its lineup (protocol.html §5). The wizard uses this to lay down the first + * heat(s) once the field is known; the rest of the config is held locally (see the + * module note on the gap). + */ +export function scheduleHeatCommand(heat: HeatId, lineup: CompetitorRef[]): Command { + return { ScheduleHeat: { heat, lineup } }; +} diff --git a/frontend/apps/rd-console/src/lib/transitions.ts b/frontend/apps/rd-console/src/lib/transitions.ts new file mode 100644 index 0000000..5a57d38 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/transitions.ts @@ -0,0 +1,170 @@ +/** + * The heat-loop transition model for live race control (#54). + * + * The heat loop is a linear forward path with off-ramps (race-engine.html §2, + * protocol.html §1): + * + * Scheduled → Staged → Armed → Running → Landed → Scored → (Advanced) + * + * `Command` exposes one variant per forward step (`Stage`/`Arm`/`Start`/`Finish`/ + * `Score`/`Advance`) and three off-ramps (`Abort`/`Restart`/`Discard`). Each carries + * the same `{ heat }` payload and requests the matching `HeatTransition`; the engine + * validates legality against the heat's *current* state — but the console disables + * illegal actions up front so the RD never fires a command that can only fail + * (clients.html §5: "reversible mistakes", progressive disclosure). + * + * The phase the projection reports is `HeatPhase` (the folded view). This module is + * the single source of truth for "given this phase, which actions are legal, and what + * `Command` does each emit" — built once here, unit-tested exhaustively, and consumed + * by the live-control screen so the buttons and the tests agree by construction. + */ + +import type { Command, HeatId, HeatPhase } from '@gridfpv/types'; + +/** + * The console-facing name of a heat-loop action. Mirrors the forward + * `Command`/`HeatTransition` steps plus the three off-ramps. (`Start` enters + * `Running`; `Finish` enters the projected `Landed` phase; `Score` enters `Scored`.) + */ +export type HeatAction = + | 'Stage' + | 'Arm' + | 'Start' + | 'Finish' + | 'Score' + | 'Advance' + | 'Abort' + | 'Restart' + | 'Discard'; + +/** Actions that destroy or rewind progress — the console confirms these (§5). */ +export const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set([ + 'Abort', + 'Restart', + 'Discard' +]); + +/** The forward "primary" action for each phase (the obvious next step), if any. */ +const PRIMARY_BY_PHASE: Record = { + Scheduled: 'Stage', + Staged: 'Arm', + Armed: 'Start', + Running: 'Finish', + Landed: 'Score', + Scored: 'Advance' +}; + +/** + * Which actions are legal in each phase. + * + * Forward steps follow the linear path. The off-ramps are available where they make + * sense: + * • `Abort` — bail out of a heat that has been committed to but not yet scored + * (Staged/Armed/Running): stop it where it is. + * • `Restart` — re-run from the top once committed (Staged/Armed/Running/Landed): + * a bad start, a crash before the window, a contested run. + * • `Discard` — throw the heat away entirely once it has results to throw away + * (Landed/Scored): it should never have counted. + * + * The engine is the final authority (it re-validates), so this errs toward the RD's + * mental model rather than encoding every edge; an over-permissive entry simply + * yields a `CommandAck` error the screen surfaces. + */ +const LEGAL_BY_PHASE: Record> = { + Scheduled: new Set(['Stage']), + Staged: new Set(['Arm', 'Abort', 'Restart']), + Armed: new Set(['Start', 'Abort', 'Restart']), + Running: new Set(['Finish', 'Abort', 'Restart']), + Landed: new Set(['Score', 'Restart', 'Discard']), + Scored: new Set(['Advance', 'Discard']) +}; + +/** The display order actions render in (forward steps first, then off-ramps). */ +export const ACTION_ORDER: readonly HeatAction[] = [ + 'Stage', + 'Arm', + 'Start', + 'Finish', + 'Score', + 'Advance', + 'Abort', + 'Restart', + 'Discard' +]; + +/** Is `action` legal to fire while the heat is in `phase`? */ +export function isActionLegal(phase: HeatPhase, action: HeatAction): boolean { + return LEGAL_BY_PHASE[phase].has(action); +} + +/** Every action legal in `phase`, in {@link ACTION_ORDER}. */ +export function legalActions(phase: HeatPhase): HeatAction[] { + return ACTION_ORDER.filter((a) => isActionLegal(phase, a)); +} + +/** The single forward "primary" action for `phase`, or `null` at no obvious step. */ +export function primaryAction(phase: HeatPhase): HeatAction | null { + return PRIMARY_BY_PHASE[phase]; +} + +/** Does this action need a confirm before firing (clients.html §5)? */ +export function isDestructive(action: HeatAction): boolean { + return DESTRUCTIVE_ACTIONS.has(action); +} + +/** + * Build the `Command` an action emits for a heat. Every heat-loop action maps to a + * single externally-tagged `Command` variant carrying `{ heat }` — the action name + * *is* the variant tag, so this stays a total, mechanical mapping. + */ +export function commandForAction(action: HeatAction, heat: HeatId): Command { + switch (action) { + case 'Stage': + return { Stage: { heat } }; + case 'Arm': + return { Arm: { heat } }; + case 'Start': + return { Start: { heat } }; + case 'Finish': + return { Finish: { heat } }; + case 'Score': + return { Score: { heat } }; + case 'Advance': + return { Advance: { heat } }; + case 'Abort': + return { Abort: { heat } }; + case 'Restart': + return { Restart: { heat } }; + case 'Discard': + return { Discard: { heat } }; + } +} + +/** A short human label for an action button. */ +export function actionLabel(action: HeatAction): string { + return action; +} + +/** A one-line description of what an action does, for tooltips / confirms. */ +export function actionDescription(action: HeatAction): string { + switch (action) { + case 'Stage': + return 'Call pilots to the line and stage the heat.'; + case 'Arm': + return 'Arm the heat — pilots ready, timer armed.'; + case 'Start': + return 'Start the race. The clock begins.'; + case 'Finish': + return 'End the race window. Pilots land.'; + case 'Score': + return 'Score the heat and lock in the result.'; + case 'Advance': + return 'Advance to the next heat.'; + case 'Abort': + return 'Abort this heat where it is (no result).'; + case 'Restart': + return 'Throw out this run and re-stage from the top.'; + case 'Discard': + return 'Discard this heat entirely — it will not count.'; + } +} diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte new file mode 100644 index 0000000..1f492c8 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -0,0 +1,188 @@ + + +
+
+
+ {#if heat} + Current heat + {heat} + {:else} + No heat on the timer + {/if} +
+
{phase}
+
+ +
+ {#if live?.on_deck} +
+ On deck + {live.on_deck} +
+ {/if} +
+ + {#if session.lastCommandError} + session.clearCommandError()} /> + {/if} + +
+ {#each ACTION_ORDER as action (action)} + {@const legal = isActionLegal(phase, action)} + fire(action)} + confirm={isDestructive(action)} + disabled={!legal || !heat} + variant={action === primary ? 'primary' : isDestructive(action) ? 'danger' : 'default'} + title={actionDescription(action)} + > + {action} + + {/each} +
+ +
+
+

Heat sheet

+ {#if live} + + {:else} +

Waiting for a live heat…

+ {/if} +
+
+

Live standing

+ {#if liveResult} + + {:else} +

No laps yet.

+ {/if} +
+
+
+ + diff --git a/frontend/apps/rd-console/src/screens/Login.svelte b/frontend/apps/rd-console/src/screens/Login.svelte new file mode 100644 index 0000000..e914997 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/Login.svelte @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte new file mode 100644 index 0000000..48f7f06 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -0,0 +1,253 @@ + + +
+
+

+ Marshaling{#if heat} — {heat}{/if} +

+

Review and correct the laps. Corrections re-fold the result live.

+
+ + {#if session.lastCommandError} + session.clearCommandError()} /> + {/if} + + {#if laps} +
+ {#each laps.competitors as cl (cl.competitor.competitor)} +
+

{cl.competitor.competitor}

+
    + {#each cl.laps as lap (lap.number)} +
  1. Lap {lap.number}: {formatMicros(lap.duration_micros)}
  2. + {/each} +
+
+ {/each} +
+ {:else} +

No lap list loaded; act on the live lineup below.

+ {/if} + +
+
+ Insert a missed lap + + + +
+ +
+ Void / adjust a detection + + + + +
+ +
+ Apply a penalty + + + {#if penaltyKind === 'time'} + + {/if} + +
+ +
+ Void the heat +

Throws out the whole heat — it will not count.

+ + Void heat + +
+
+
+ + diff --git a/frontend/apps/rd-console/src/screens/Registration.svelte b/frontend/apps/rd-console/src/screens/Registration.svelte new file mode 100644 index 0000000..6159e71 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/Registration.svelte @@ -0,0 +1,155 @@ + + +
+
+

Registration

+

+ Bind each competitor the timer has seen on {adapter} to a pilot. +

+
+ + {#if session.lastCommandError} + session.clearCommandError()} /> + {/if} + + {#if seen.length === 0} +

No competitors seen yet. They appear here as the timer reports them.

+ {:else} + + + + + + + + + + {#each seen as ref (ref)} + + + + + + {/each} + +
Competitor (source ref)PilotAction
{ref} + pilotInputs[ref] ?? bound[ref] ?? '', + (v) => (pilotInputs = { ...pilotInputs, [ref]: v }) + } + /> + + + {#if bound[ref]} + ✓ {bound[ref]} + {/if} +
+ {/if} +
+ + diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte new file mode 100644 index 0000000..606ebc6 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -0,0 +1,99 @@ + + +
+
+

Results

+ +
+ + {#if heatResult} +
+

Heat result

+ +
+ {/if} + + {#if standings && standings.length > 0} +
+

Standings

+ +
+ {/if} + + {#if bracket && bracket.rounds.length > 0} +
+

Bracket

+ +
+ {/if} + + {#if !heatResult && !(standings && standings.length) && !(bracket && bracket.rounds.length)} +

No results yet. They appear here as heats are scored.

+ {/if} +
+ + diff --git a/frontend/apps/rd-console/src/screens/SetupWizard.svelte b/frontend/apps/rd-console/src/screens/SetupWizard.svelte new file mode 100644 index 0000000..12652b1 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/SetupWizard.svelte @@ -0,0 +1,315 @@ + + +
+
    + {#each steps as s, i (s)} +
  1. + +
  2. + {/each} +
+ +
+ {#if step === 0} +

Event

+ + + {:else if step === 1} +

Classes

+ {#if config.classes.length === 0} +

No classes yet.

+ {/if} + {#each config.classes as cls, i (i)} +
+ + + +
+ {/each} + + {:else if step === 2} +

Track

+ + {:else if step === 3} +

Format & win condition

+ {#if config.classes.length === 0} +

Add a class first.

+ {/if} + {#each config.classes as cls, i (i)} +
+ {cls.name} + + {#if cls.winCondition !== 'BestLap'} + + {:else} + Best single lap. + {/if} +
+ {/each} + {:else} +

Review

+
+
Event
+
{config.eventName} ({config.eventId})
+
Track
+
{config.track}
+
Classes
+
+
    + {#each config.classes as cls (cls.id)} +
  • {cls.name} — {FORMAT_LABELS[cls.format]}
  • + {/each} +
+
+
+ {#if problems.length > 0} +
    + {#each problems as p (p)}
  • {p}
  • {/each} +
+ {/if} + + {/if} +
+ + +
+ + diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts new file mode 100644 index 0000000..7cb14a8 --- /dev/null +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import LiveRaceControl from '../src/screens/LiveRaceControl.svelte'; +import { makeTestSession } from './support.js'; +import { liveRunning, failAck } from './fixtures.js'; + +describe('LiveRaceControl', () => { + it('enables only the phase-legal transitions (Running → Finish/Abort/Restart)', () => { + const { session } = makeTestSession({ live: liveRunning }); + render(LiveRaceControl, { session }); + + const btn = (label: string) => screen.getByRole('button', { name: label }) as HTMLButtonElement; + // Forward step + off-ramps legal in Running. + expect(btn('Finish').disabled).toBe(false); + expect(btn('Abort').disabled).toBe(false); + expect(btn('Restart').disabled).toBe(false); + // Illegal in Running. + expect(btn('Stage').disabled).toBe(true); + expect(btn('Arm').disabled).toBe(true); + expect(btn('Score').disabled).toBe(true); + expect(btn('Advance').disabled).toBe(true); + expect(btn('Discard').disabled).toBe(true); + }); + + it('fires the matching Command for a forward transition', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(LiveRaceControl, { session }); + + await fireEvent.click(screen.getByRole('button', { name: 'Finish' })); + expect(sendSpy).toHaveBeenCalledWith({ Finish: { heat: 'heat-1' } }); + }); + + it('requires a confirm before a destructive off-ramp, then fires it', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(LiveRaceControl, { session }); + + // First click arms the confirm (does NOT send). + await fireEvent.click(screen.getByRole('button', { name: 'Abort' })); + expect(sendSpy).not.toHaveBeenCalled(); + + // Confirm fires the Abort command. + await fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(sendSpy).toHaveBeenCalledWith({ Abort: { heat: 'heat-1' } }); + }); + + it('surfaces a failed CommandAck error to the RD', async () => { + const { session } = makeTestSession({ live: liveRunning, ack: failAck }); + render(LiveRaceControl, { session }); + + await fireEvent.click(screen.getByRole('button', { name: 'Finish' })); + expect(await screen.findByRole('alert')).toHaveTextContent('illegal transition'); + }); + + it('renders the live leaderboard from the running order', () => { + const { session } = makeTestSession({ live: liveRunning }); + render(LiveRaceControl, { session }); + // Heat sheet + live standing both list the lineup. + expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts new file mode 100644 index 0000000..613ca22 --- /dev/null +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import Marshaling from '../src/screens/Marshaling.svelte'; +import { makeTestSession } from './support.js'; +import { liveRunning, lapList } from './fixtures.js'; + +describe('Marshaling', () => { + it('renders the per-competitor lap list', () => { + const { session } = makeTestSession({ live: liveRunning }); + render(Marshaling, { session, laps: lapList }); + expect(screen.getByText('Lap 1: 41.000')).toBeInTheDocument(); + }); + + it('emits a VoidHeat command after confirming the destructive action', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(Marshaling, { session }); + + await fireEvent.click(screen.getByRole('button', { name: 'Void heat' })); + expect(sendSpy).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(sendSpy).toHaveBeenCalledWith({ VoidHeat: { heat: 'heat-1' } }); + }); + + it('emits an ApplyPenalty (time added) command', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(Marshaling, { session }); + + await fireEvent.change(screen.getByLabelText('Penalty competitor'), { + target: { value: 'BOB' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply penalty' })); + + expect(sendSpy).toHaveBeenCalledWith({ + ApplyPenalty: { + heat: 'heat-1', + competitor: 'BOB', + penalty: { TimeAdded: { micros: 2_000_000n } } + } + }); + }); + + it('emits a VoidDetection command for a log offset', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(Marshaling, { session }); + + await fireEvent.click(screen.getByRole('button', { name: 'Void detection' })); + expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 0n } }); + }); +}); diff --git a/frontend/apps/rd-console/tests/RegistrationScreen.test.ts b/frontend/apps/rd-console/tests/RegistrationScreen.test.ts new file mode 100644 index 0000000..98f3dab --- /dev/null +++ b/frontend/apps/rd-console/tests/RegistrationScreen.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent, within } from '@testing-library/dom'; +import Registration from '../src/screens/Registration.svelte'; +import { makeTestSession } from './support.js'; +import { liveRunning } from './fixtures.js'; + +describe('Registration', () => { + it('lists the seen competitor refs from the live lineup', () => { + const { session } = makeTestSession({ live: liveRunning }); + render(Registration, { session }); + expect(screen.getByText('ALICE')).toBeInTheDocument(); + expect(screen.getByText('BOB')).toBeInTheDocument(); + }); + + it('emits a Register command binding the ref to the typed pilot id', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning }); + render(Registration, { session, adapter: 'rh-1' }); + + const input = screen.getByLabelText('Pilot for ALICE') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'pilot-alice' } }); + // Scope to ALICE's row (each row has its own Register button). + const row = input.closest('tr')!; + await fireEvent.click(within(row).getByRole('button', { name: 'Register' })); + + expect(sendSpy).toHaveBeenCalledWith({ + Register: { adapter: 'rh-1', competitor: 'ALICE', pilot: 'pilot-alice' } + }); + }); + + it('shows an empty state when no competitors are seen yet', () => { + const { session } = makeTestSession({ live: { phase: 'Scheduled' } }); + render(Registration, { session }); + expect(screen.getByText(/No competitors seen yet/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts new file mode 100644 index 0000000..5773c1b --- /dev/null +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Results from '../src/screens/Results.svelte'; +import { heatResult, standings, eventOutcome } from './fixtures.js'; + +describe('Results', () => { + it('renders a heat result, standings, and bracket from typed fixtures', () => { + render(Results, { heatResult, standings, outcome: eventOutcome }); + // Leaderboard + standings list competitors. + expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); + // Bracket round names from the derivation. + expect(screen.getByText('Final')).toBeInTheDocument(); + expect(screen.getByText('Semifinals')).toBeInTheDocument(); + }); + + it('shows an empty state when nothing is scored yet', () => { + render(Results, {}); + expect(screen.getByText(/No results yet/i)).toBeInTheDocument(); + }); + + it('offers an export action', () => { + render(Results, { heatResult }); + expect(screen.getByRole('button', { name: 'Export JSON' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/SetupWizard.test.ts b/frontend/apps/rd-console/tests/SetupWizard.test.ts new file mode 100644 index 0000000..defa247 --- /dev/null +++ b/frontend/apps/rd-console/tests/SetupWizard.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import SetupWizard from '../src/screens/SetupWizard.svelte'; +import { emptyConfig, type EventConfig } from '../src/lib/setup.js'; + +describe('SetupWizard', () => { + it('walks the steps and commits a complete config', async () => { + const config: EventConfig = { + eventId: 'spring', + eventName: 'Spring Cup', + track: 'Main field', + classes: [{ id: 'open', name: 'Open', format: 'timed-qual', winCondition: 'BestLap' }] + }; + const oncommit = vi.fn(); + render(SetupWizard, { config, oncommit }); + + // Jump to the Review step. + await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); + await fireEvent.click(screen.getByRole('button', { name: 'Save configuration' })); + + expect(oncommit).toHaveBeenCalledOnce(); + expect(oncommit.mock.calls[0][0]).toMatchObject({ eventId: 'spring', eventName: 'Spring Cup' }); + }); + + it('blocks finishing an incomplete config and lists the problems', async () => { + const oncommit = vi.fn(); + render(SetupWizard, { config: emptyConfig(), oncommit }); + + await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); + const save = screen.getByRole('button', { name: 'Save configuration' }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + expect(screen.getByText('Event needs a name.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/control.test.ts b/frontend/apps/rd-console/tests/control.test.ts new file mode 100644 index 0000000..6d32780 --- /dev/null +++ b/frontend/apps/rd-console/tests/control.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Command } from '@gridfpv/types'; +import { createControlClient, stringifyCommand, type FetchLike } from '../src/lib/control.js'; +import { okAck, failAck } from './fixtures.js'; + +function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }): Response { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: async () => body + } as unknown as Response; +} + +/** A typed fetch mock so `.mock.calls[0]` is `[url, init]`, not `[]`. */ +function mockFetch(impl: FetchLike) { + return vi.fn(impl); +} + +describe('createControlClient', () => { + it('POSTs the JSON-serialized Command to {baseUrl}/control with the bearer token', async () => { + const fetch = mockFetch(async () => jsonResponse(okAck)); + const client = createControlClient('http://d.local:8080/', 'tok-123', { fetch }); + const cmd: Command = { Stage: { heat: 'heat-1' } }; + + const ack = await client.sendCommand(cmd); + + expect(ack).toEqual(okAck); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('http://d.local:8080/control'); + expect(init?.method).toBe('POST'); + const headers = init?.headers as Record; + expect(headers.Authorization).toBe('Bearer tok-123'); + expect(headers['Content-Type']).toBe('application/json'); + expect(JSON.parse(init?.body as string)).toEqual({ Stage: { heat: 'heat-1' } }); + }); + + it('omits the Authorization header when no token is given', async () => { + const fetch = mockFetch(async () => jsonResponse(okAck)); + const client = createControlClient('http://d.local', undefined, { fetch }); + await client.sendCommand({ Arm: { heat: 'h' } }); + const headers = fetch.mock.calls[0][1]?.headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it('passes through a failed CommandAck (ok:false + ProtocolError) verbatim', async () => { + const fetch = mockFetch(async () => jsonResponse(failAck, { ok: false, status: 409 })); + const client = createControlClient('http://d.local', 't', { fetch }); + const ack = await client.sendCommand({ Start: { heat: 'h' } }); + expect(ack.ok).toBe(false); + expect(ack.error).toEqual({ code: 'BadRequest', message: 'illegal transition' }); + }); + + it('synthesizes a failed ack from a bare ProtocolError body on non-2xx', async () => { + const fetch = mockFetch(async () => + jsonResponse({ code: 'Unauthorized', message: 'no' }, { ok: false, status: 401 }) + ); + const client = createControlClient('http://d.local', 't', { fetch }); + const ack = await client.sendCommand({ Finish: { heat: 'h' } }); + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('Unauthorized'); + }); + + it('never rejects on a transport failure — resolves a failed ack instead', async () => { + const fetch = mockFetch(async () => { + throw new Error('network down'); + }); + const client = createControlClient('http://d.local', 't', { fetch }); + const ack = await client.sendCommand({ Score: { heat: 'h' } }); + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('Internal'); + }); + + it('renders bigint command fields as JSON numbers (serde u64 default)', () => { + const cmd: Command = { AdjustLap: { target: 42n, at: 1_500_000n } }; + expect(JSON.parse(stringifyCommand(cmd))).toEqual({ + AdjustLap: { target: 42, at: 1_500_000 } + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/fixtures.ts b/frontend/apps/rd-console/tests/fixtures.ts new file mode 100644 index 0000000..97c9ddc --- /dev/null +++ b/frontend/apps/rd-console/tests/fixtures.ts @@ -0,0 +1,139 @@ +/** + * Typed fixtures for the console tests — built straight from `@gridfpv/types`, so a + * contract change surfaces as a compile error here too. + */ +import type { + CommandAck, + EventOutcome, + HeatResult, + LapList, + LiveRaceState, + RankEntry +} from '@gridfpv/types'; + +export const liveRunning: LiveRaceState = { + current_heat: 'heat-1', + phase: 'Running', + active_pilots: ['ALICE', 'BOB', 'CARMEN'], + progress: [ + { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000n }, + { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000n }, + { competitor: 'CARMEN', laps_completed: 2, last_lap_micros: undefined } + ], + running_order: ['ALICE', 'BOB', 'CARMEN'], + on_deck: 'heat-2' +}; + +export const heatResult: HeatResult = { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_250_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 42_100_000n } + } + ] +}; + +export const standings: RankEntry[] = [ + { competitor: 'ALICE', position: 1 }, + { competitor: 'BOB', position: 2 }, + { competitor: 'CARMEN', position: 3 } +]; + +export const lapList: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000n }, + { number: 2, duration_micros: 40_500_000n } + ] + }, + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + laps: [{ number: 1, duration_micros: 43_000_000n }] + } + ] +}; + +export const eventOutcome: EventOutcome = { + qualifying: standings, + qualifying_heats: [], + bracket_seeds: ['ALICE', 'BOB', 'CARMEN', 'DANA'], + bracket: [ + { competitor: 'ALICE', position: 1 }, + { competitor: 'BOB', position: 2 } + ], + bracket_heats: [ + { + heat: 'sf-1', + result: { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_000_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'DANA' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 45_000_000n } + } + ] + } + }, + { + heat: 'sf-2', + result: { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 42_000_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 46_000_000n } + } + ] + } + }, + { + heat: 'final', + result: { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 40_000_000n } + }, + { + competitor: { adapter: 'rh-1', competitor: 'BOB' }, + position: 2, + laps: 3, + metric: { BestLapMicros: 41_500_000n } + } + ] + } + } + ] +}; + +export const okAck: CommandAck = { ok: true }; +export const failAck: CommandAck = { + ok: false, + error: { code: 'BadRequest', message: 'illegal transition' } +}; diff --git a/frontend/apps/rd-console/tests/marshaling.test.ts b/frontend/apps/rd-console/tests/marshaling.test.ts new file mode 100644 index 0000000..c9e0ef3 --- /dev/null +++ b/frontend/apps/rd-console/tests/marshaling.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + adjustLapCommand, + applyPenaltyCommand, + DISQUALIFY, + insertLapCommand, + secondsToSourceTime, + timeAddedPenalty, + voidDetectionCommand, + voidHeatCommand +} from '../src/lib/marshaling.js'; + +describe('marshaling command builders', () => { + it('voidDetectionCommand targets a log offset', () => { + expect(voidDetectionCommand(7n)).toEqual({ VoidDetection: { target: 7n } }); + }); + + it('insertLapCommand carries adapter, competitor, and source time', () => { + expect(insertLapCommand('rh-1', 'ALICE', 1_000_000n)).toEqual({ + InsertLap: { adapter: 'rh-1', competitor: 'ALICE', at: 1_000_000n } + }); + }); + + it('adjustLapCommand re-times a logged pass', () => { + expect(adjustLapCommand(3n, 2_000_000n)).toEqual({ AdjustLap: { target: 3n, at: 2_000_000n } }); + }); + + it('voidHeatCommand voids the whole heat', () => { + expect(voidHeatCommand('heat-1')).toEqual({ VoidHeat: { heat: 'heat-1' } }); + }); + + it('applyPenaltyCommand carries a TimeAdded penalty in micros', () => { + expect(applyPenaltyCommand('heat-1', 'BOB', timeAddedPenalty(2))).toEqual({ + ApplyPenalty: { + heat: 'heat-1', + competitor: 'BOB', + penalty: { TimeAdded: { micros: 2_000_000n } } + } + }); + }); + + it('applyPenaltyCommand carries a Disqualify penalty', () => { + expect(applyPenaltyCommand('heat-1', 'BOB', DISQUALIFY)).toEqual({ + ApplyPenalty: { heat: 'heat-1', competitor: 'BOB', penalty: 'Disqualify' } + }); + }); + + it('converts whole seconds to microsecond SourceTime', () => { + expect(secondsToSourceTime(1.5)).toBe(1_500_000n); + }); +}); diff --git a/frontend/apps/rd-console/tests/registration.test.ts b/frontend/apps/rd-console/tests/registration.test.ts new file mode 100644 index 0000000..7d4f658 --- /dev/null +++ b/frontend/apps/rd-console/tests/registration.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { registerCommand } from '../src/lib/registration.js'; + +describe('registerCommand', () => { + it('binds a source-local competitor to an event-scoped pilot', () => { + expect(registerCommand('rh-1', 'seat-2', 'pilot-alice')).toEqual({ + Register: { adapter: 'rh-1', competitor: 'seat-2', pilot: 'pilot-alice' } + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/results.test.ts b/frontend/apps/rd-console/tests/results.test.ts new file mode 100644 index 0000000..e361b55 --- /dev/null +++ b/frontend/apps/rd-console/tests/results.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { bracketFromOutcome, toExportJson } from '../src/lib/results.js'; +import { eventOutcome } from './fixtures.js'; + +describe('bracketFromOutcome', () => { + it('lays completed bracket heats into rounds (final last), marking winners', () => { + const bracket = bracketFromOutcome(eventOutcome); + // 3 heats → semis (2) + final (1). + expect(bracket.rounds.map((r) => r.name)).toEqual(['Semifinals', 'Final']); + expect(bracket.rounds[0].matches).toHaveLength(2); + expect(bracket.rounds[1].matches).toHaveLength(1); + + const final = bracket.rounds[1].matches[0]; + expect(final.heat).toBe('final'); + const winner = final.slots.find((s) => s.winner); + expect(winner?.competitor).toBe('ALICE'); + }); + + it('returns no rounds for an outcome with no bracket heats', () => { + expect(bracketFromOutcome({ ...eventOutcome, bracket_heats: [] }).rounds).toEqual([]); + }); +}); + +describe('toExportJson', () => { + it('serializes typed projection data with bigints as numbers', () => { + const json = toExportJson({ at: 1_000_000n }); + expect(JSON.parse(json)).toEqual({ at: 1_000_000 }); + }); +}); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts new file mode 100644 index 0000000..d9a3a1d --- /dev/null +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; +import type { CommandAck } from '@gridfpv/types'; +import { Session } from '../src/lib/session.svelte.js'; +import { liveRunning, okAck, failAck } from './fixtures.js'; + +/** A mock ProtocolClient that lets a test push state into the session. */ +function mockConnect(initial: ProtocolState) { + let listener: StateListener | undefined; + const client: ProtocolClient = { + baseUrl: 'http://d.local', + scope: { Event: { event: 'e' } }, + getState: () => initial, + onState: (l) => { + listener = l; + l(initial); + return () => (listener = undefined); + }, + close: vi.fn() + }; + const connect = vi.fn(() => client); + const push = (s: ProtocolState) => listener?.(s); + return { connect, client, push }; +} + +describe('Session', () => { + it('login holds auth, opens both seams, and surfaces live state from the stream', () => { + const { connect, push } = mockConnect({ + body: undefined, + cursor: undefined, + status: 'connecting', + error: undefined + }); + const control = { baseUrl: 'http://d.local', sendCommand: vi.fn(async () => okAck) }; + const controlFactory = vi.fn(() => control); + + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.login('http://d.local', 'tok'); + + expect(session.authenticated).toBe(true); + expect(connect).toHaveBeenCalledOnce(); + expect(controlFactory).toHaveBeenCalledWith('http://d.local', 'tok'); + + // Stream pushes a LiveRaceState body → session.liveState reflects it. + push({ body: { LiveRaceState: liveRunning }, cursor: 1n, status: 'live', error: undefined }); + expect(session.connectionStatus).toBe('live'); + expect(session.liveState?.current_heat).toBe('heat-1'); + }); + + it('send routes a Command through the control client and records errors', async () => { + const { connect } = mockConnect({ + body: undefined, + cursor: undefined, + status: 'live', + error: undefined + }); + const sendCommand = vi.fn(async (): Promise => failAck); + const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); + + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.login('http://d.local', 'tok'); + + const ack = await session.send({ Stage: { heat: 'heat-1' } }); + expect(sendCommand).toHaveBeenCalledWith({ Stage: { heat: 'heat-1' } }); + expect(ack.ok).toBe(false); + expect(session.lastCommandError?.code).toBe('BadRequest'); + + session.clearCommandError(); + expect(session.lastCommandError).toBeUndefined(); + }); + + it('refuses to send when not signed in', async () => { + const session = new Session({ autoRestore: false }); + const ack = await session.send({ Stage: { heat: 'h' } }); + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('Unauthorized'); + }); + + it('logout tears down the read client and clears state', () => { + const { connect, client } = mockConnect({ + body: undefined, + cursor: undefined, + status: 'live', + error: undefined + }); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.login('http://d.local', 'tok'); + + session.logout(); + expect(client.close).toHaveBeenCalled(); + expect(session.authenticated).toBe(false); + expect(session.liveState).toBeUndefined(); + }); +}); diff --git a/frontend/apps/rd-console/tests/setup.test.ts b/frontend/apps/rd-console/tests/setup.test.ts new file mode 100644 index 0000000..678bd7a --- /dev/null +++ b/frontend/apps/rd-console/tests/setup.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + defaultClass, + defaultWinCondition, + emptyConfig, + isConfigComplete, + scheduleHeatCommand, + validateConfig, + type EventConfig +} from '../src/lib/setup.js'; + +describe('setup config', () => { + it('seeds an empty config that is not yet complete', () => { + const c = emptyConfig(); + expect(isConfigComplete(c)).toBe(false); + expect(validateConfig(c).length).toBeGreaterThan(0); + }); + + it('picks a sensible default win condition per format', () => { + expect(defaultWinCondition('timed-qual')).toEqual({ Timed: { window_micros: 120_000_000n } }); + expect(defaultWinCondition('single-elim')).toEqual({ FirstToLaps: { n: 3 } }); + expect(defaultWinCondition('zippyq')).toEqual({ BestConsecutive: { n: 3 } }); + }); + + it('validates a complete config as ready', () => { + const c: EventConfig = { + eventId: 'spring', + eventName: 'Spring Cup', + track: 'Main field', + classes: [defaultClass('open', 'Open', 'timed-qual')] + }; + expect(validateConfig(c)).toEqual([]); + expect(isConfigComplete(c)).toBe(true); + }); + + it('flags each missing piece', () => { + const problems = validateConfig({ eventId: '', eventName: '', track: '', classes: [] }); + expect(problems).toContain('Event needs a name.'); + expect(problems).toContain('Add at least one class.'); + }); + + it('builds the one supported setup command: ScheduleHeat with a lineup', () => { + expect(scheduleHeatCommand('heat-1', ['ALICE', 'BOB'])).toEqual({ + ScheduleHeat: { heat: 'heat-1', lineup: ['ALICE', 'BOB'] } + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts new file mode 100644 index 0000000..62a1132 --- /dev/null +++ b/frontend/apps/rd-console/tests/support.ts @@ -0,0 +1,51 @@ +/** + * Test support: build a `Session` whose seams are mocked so screens render and fire + * commands without a real server. The returned `sendSpy` records every `Command` a + * screen emits; `pushLive` injects a `LiveRaceState` onto the read stream. + */ +import { vi } from 'vitest'; +import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; +import type { Command, CommandAck, LiveRaceState } from '@gridfpv/types'; +import { Session } from '../src/lib/session.svelte.js'; + +export interface TestSession { + session: Session; + sendSpy: ReturnType Promise>>; + pushLive: (state: LiveRaceState) => void; +} + +export function makeTestSession(opts?: { ack?: CommandAck; live?: LiveRaceState }): TestSession { + const ack: CommandAck = opts?.ack ?? { ok: true }; + const sendSpy = vi.fn<(c: Command) => Promise>(async () => ack); + + let listener: StateListener | undefined; + const initial: ProtocolState = { + body: opts?.live ? { LiveRaceState: opts.live } : undefined, + cursor: undefined, + status: 'live', + error: undefined + }; + const client: ProtocolClient = { + baseUrl: 'http://d.local', + scope: { Event: { event: 'e' } }, + getState: () => initial, + onState: (l) => { + listener = l; + l(initial); + return () => (listener = undefined); + }, + close: () => {} + }; + + const session = new Session({ + connectImpl: () => client, + controlFactory: () => ({ baseUrl: 'http://d.local', sendCommand: sendSpy }), + autoRestore: false + }); + session.login('http://d.local', 'tok'); + + const pushLive = (state: LiveRaceState) => + listener?.({ body: { LiveRaceState: state }, cursor: 1n, status: 'live', error: undefined }); + + return { session, sendSpy, pushLive }; +} diff --git a/frontend/apps/rd-console/tests/transitions.test.ts b/frontend/apps/rd-console/tests/transitions.test.ts new file mode 100644 index 0000000..84218ca --- /dev/null +++ b/frontend/apps/rd-console/tests/transitions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { HeatPhase } from '@gridfpv/types'; +import { + ACTION_ORDER, + commandForAction, + isActionLegal, + isDestructive, + legalActions, + primaryAction, + type HeatAction +} from '../src/lib/transitions.js'; + +const PHASES: HeatPhase[] = ['Scheduled', 'Staged', 'Armed', 'Running', 'Landed', 'Scored']; + +describe('transitions: phase → legal actions', () => { + it('maps each phase to its forward primary action', () => { + expect(primaryAction('Scheduled')).toBe('Stage'); + expect(primaryAction('Staged')).toBe('Arm'); + expect(primaryAction('Armed')).toBe('Start'); + expect(primaryAction('Running')).toBe('Finish'); + expect(primaryAction('Landed')).toBe('Score'); + expect(primaryAction('Scored')).toBe('Advance'); + }); + + it('only allows the forward step from Scheduled (no off-ramps before staging)', () => { + expect(legalActions('Scheduled')).toEqual(['Stage']); + expect(isActionLegal('Scheduled', 'Abort')).toBe(false); + expect(isActionLegal('Scheduled', 'Arm')).toBe(false); + }); + + it('allows abort/restart once committed (Staged/Armed/Running) but not from Scheduled', () => { + for (const p of ['Staged', 'Armed', 'Running'] as HeatPhase[]) { + expect(isActionLegal(p, 'Abort')).toBe(true); + expect(isActionLegal(p, 'Restart')).toBe(true); + } + expect(isActionLegal('Scheduled', 'Restart')).toBe(false); + }); + + it('allows discard only where there is a result to discard (Landed/Scored)', () => { + expect(isActionLegal('Landed', 'Discard')).toBe(true); + expect(isActionLegal('Scored', 'Discard')).toBe(true); + expect(isActionLegal('Running', 'Discard')).toBe(false); + }); + + it("disables a phase's non-adjacent forward steps (no skipping)", () => { + // From Staged you can Arm, not Start/Finish/Score/Advance. + expect(isActionLegal('Staged', 'Arm')).toBe(true); + for (const a of ['Start', 'Finish', 'Score', 'Advance'] as HeatAction[]) { + expect(isActionLegal('Staged', a)).toBe(false); + } + }); + + it('legalActions is always a subset of ACTION_ORDER and in that order', () => { + for (const p of PHASES) { + const legal = legalActions(p); + for (const a of legal) expect(ACTION_ORDER).toContain(a); + // monotonic index ⇒ preserves ACTION_ORDER + const idx = legal.map((a) => ACTION_ORDER.indexOf(a)); + expect(idx).toEqual([...idx].sort((x, y) => x - y)); + } + }); + + it('marks exactly the three off-ramps destructive', () => { + expect(isDestructive('Abort')).toBe(true); + expect(isDestructive('Restart')).toBe(true); + expect(isDestructive('Discard')).toBe(true); + for (const a of ['Stage', 'Arm', 'Start', 'Finish', 'Score', 'Advance'] as HeatAction[]) { + expect(isDestructive(a)).toBe(false); + } + }); +}); + +describe('transitions: action → Command', () => { + it('emits the matching externally-tagged Command variant carrying { heat }', () => { + // The action name IS the variant tag for every heat-loop action. + const actions: HeatAction[] = [ + 'Stage', + 'Arm', + 'Start', + 'Finish', + 'Score', + 'Advance', + 'Abort', + 'Restart', + 'Discard' + ]; + for (const action of actions) { + const cmd = commandForAction(action, 'heat-7'); + expect(cmd).toEqual({ [action]: { heat: 'heat-7' } }); + } + }); +}); diff --git a/frontend/apps/rd-console/tsconfig.test.json b/frontend/apps/rd-console/tsconfig.test.json new file mode 100644 index 0000000..454102f --- /dev/null +++ b/frontend/apps/rd-console/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + // Type-checks the console's tests (kept out of `src` so the app build never bundles + // them). Run by the `check` script; Vitest runs them at runtime via vitest.config.ts. + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["svelte", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["tests", "vitest.setup.ts", "src"] +} diff --git a/frontend/apps/rd-console/vitest.config.ts b/frontend/apps/rd-console/vitest.config.ts new file mode 100644 index 0000000..01d0fb7 --- /dev/null +++ b/frontend/apps/rd-console/vitest.config.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { svelteTesting } from '@testing-library/svelte/vite'; + +// The console is typed against `@gridfpv/types`, whose source re-exports the ts-rs +// bindings through the `@bindings/*` alias (see tsconfig.base.json). Vitest uses +// Vite's resolver, not tsconfig `paths`, so mirror the alias here pointing at the +// repo-root `bindings/`. Those imports are all `export type`, so nothing of the +// bindings executes — this only satisfies resolution. (Mirrors the component lib's +// vitest config.) +export default defineConfig({ + // Skip the Svelte preprocessor inside the Vitest worker (it trips on a partial Vite + // environment); these screens are plain Svelte 5 + plain CSS, and production builds + // still preprocess via svelte.config.js. Same trade-off the component library makes. + plugins: [svelte({ preprocess: [] }), svelteTesting()], + resolve: { + // The workspace packages publish from `dist` (built by `npm run build`), but the + // tests run against source so they don't need a prior build. Point each package at + // its `src` entry; the svelte plugin handles the `.svelte` files behind the + // component barrel. `@bindings` mirrors the tsconfig path alias (type-only). + alias: { + '@bindings': fileURLToPath(new URL('../../../bindings', import.meta.url)), + '@gridfpv/components': fileURLToPath( + new URL('../../packages/components/src/index.ts', import.meta.url) + ), + '@gridfpv/components/tokens.css': fileURLToPath( + new URL('../../packages/components/src/tokens.css', import.meta.url) + ), + '@gridfpv/protocol-client': fileURLToPath( + new URL('../../packages/protocol-client/src/index.ts', import.meta.url) + ), + '@gridfpv/types': fileURLToPath(new URL('../../packages/types/src/index.ts', import.meta.url)) + } + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./vitest.setup.ts'], + include: ['tests/**/*.test.ts'] + } +}); diff --git a/frontend/apps/rd-console/vitest.setup.ts b/frontend/apps/rd-console/vitest.setup.ts new file mode 100644 index 0000000..ae97d62 --- /dev/null +++ b/frontend/apps/rd-console/vitest.setup.ts @@ -0,0 +1,3 @@ +// Extends Vitest's `expect` with jest-dom matchers (toBeInTheDocument, etc.) and +// auto-cleans the rendered DOM between tests (via @testing-library/svelte/vite). +import '@testing-library/jest-dom/vitest'; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6167016..debb119 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,10 +40,14 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.6", + "jsdom": "^25.0.1", "svelte": "^5.16.0", "svelte-check": "^4.1.1", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^2.1.8" } }, "apps/rd-console/node_modules/@sveltejs/vite-plugin-svelte": { From a77d8a5461805326f8975844497008c9b481c911 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:35:32 +0000 Subject: [PATCH 011/362] Add mock-RH end-to-end server test driving a live event to a protocol client (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend proof of v0.4's "a complete event runs live ... against dockerized RotorHazard": a real heat is driven through dockerized RH into the running protocol server's log and observed through a real protocol client (HTTP snapshot + WS change-stream). - `crates/server/Cargo.toml`: a `live` cargo feature mirroring the engine's — it enables the optional `gridfpv-testkit` + `gridfpv-adapters` (and `gridfpv-engine/live`) so the dockerized-RH harness is available only in the gated dev build; off by default, so the shipped library is unchanged. - `crates/server/tests/full_event_live.rs` (`#![cfg(feature = "live")]`, `#[ignore]`, DISTINCT RH port 5041): stands up the server on an ephemeral port over a fresh log; the RD issues itself an RD token; drives the heat loop (Schedule/Stage/Arm/Start/Finish/Score) through the privileged control path with that token, and feeds the timer's real passes through `AppState::append` (the source-adapter seam). A protocol client snapshots the event scope then subscribes `/stream` from the snapshot cursor and asserts: the snapshot body+cursor are correct; the change envelopes arrive in order with a strictly-increasing per-stream sequence, converging to the right LiveRaceState; a `VoidDetection` marshaling correction re-folds the client's pilot-scope LapList as a fresh value (down exactly one detection, or to an empty/404 scope when it was the only detection); and the auth gate rejects an un-authenticated and a read-only-join-token control command (401) while admitting the RD. Tolerant/structural for RH timing (the harness stops the heat on the first crossing): never exact µs, lap counts only `>= 1`. Core `cargo xtask ci` is unaffected (the test is feature-gated + ignored). Part of #47. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 2 + crates/server/Cargo.toml | 23 + crates/server/tests/full_event_live.rs | 571 +++++++++++++++++++++++++ 3 files changed, 596 insertions(+) create mode 100644 crates/server/tests/full_event_live.rs diff --git a/Cargo.lock b/Cargo.lock index 8bd5c21..570148f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,10 +499,12 @@ 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", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 987532d..bcaaaf4 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -6,6 +6,19 @@ 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 @@ -26,6 +39,16 @@ tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", # 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. diff --git a/crates/server/tests/full_event_live.rs b/crates/server/tests/full_event_live.rs new file mode 100644 index 0000000..2e5ba9c --- /dev/null +++ b/crates/server/tests/full_event_live.rs @@ -0,0 +1,571 @@ +//! 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 +//! [`InMemoryLog`]; the RD issues itself an RD bearer token. +//! 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 /snapshot/...` for the initial body+cursor, then a WS +//! `/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::scope::{EventId, PilotId, Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, LiveRaceState, ProjectionBody, Snapshot}; +use gridfpv_server::stream::{Change, StreamMessage}; +use gridfpv_storage::InMemoryLog; +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"; + +// --------------------------------------------------------------------------------------- +// Server / client plumbing (mirrors `tests/ws_stream.rs` + `tests/control.rs`). +// --------------------------------------------------------------------------------------- + +/// Serve `router(state)` 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). +async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(state); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("{addr}"), handle) +} + +/// `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 /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}/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().ok(); + + 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, + }), + )]; + 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 log; the RD issues itself a token. --- + let state = AppState::new(InMemoryLog::default()); + let rd = state.tokens().issue_rd_token(); + let (addr, _server) = serve(state.clone()).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()], + }, + &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, "/snapshot/event/spring-cup").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("spring-cup".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 + Arm 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::Arm { 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})" + ); + rd_command(&addr, &Command::Start { 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, "/snapshot/event/spring-cup").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, "/snapshot/pilot/spring-cup/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, "/snapshot/pilot/spring-cup/node-0").await; + let mut pilot_stream = subscribe( + &addr, + &SubscribeRequest { + scope: Scope::Pilot { + event: EventId("spring-cup".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, "/snapshot/pilot/spring-cup/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::Finish { 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::Finish { 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 finishes the heat — proving the gate admits the RD. + rd_command(&addr, &Command::Finish { heat: heat.clone() }, &rd).await; + rd_command(&addr, &Command::Score { heat: heat.clone() }, &rd).await; + + // The event-scope client converges to the Scored phase — the heat ran end to end. + await_phase(&mut stream, HeatPhase::Scored).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)) + } +} From 0ed75ee85af53ef85a933bacbdc13f82d66d1d64 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:40:27 +0000 Subject: [PATCH 012/362] Rename HeatPhase::Landed -> Finished to match canonical engine state (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-conventions §4 mandates Running/Finished (not Started/Landed) for heat states; the live-projection HeatPhase was the lone holdout. Align the wire enum + mapping + regenerated bindings + the RD-console transition model + tests. Also wire the server mock-RH e2e (#47) into cargo xtask live (was missed: a doc-comment false-matched the guard). Doc edits for protocol/clients/conventions follow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/HeatPhase.ts | 4 ++-- crates/server/src/live_state.rs | 6 +++--- crates/server/src/snapshot.rs | 7 ++++--- crates/server/tests/control.rs | 4 ++-- frontend/apps/rd-console/src/lib/transitions.ts | 12 ++++++------ frontend/apps/rd-console/tests/transitions.test.ts | 8 ++++---- frontend/packages/protocol-client/src/client.test.ts | 4 ++-- xtask/src/main.rs | 3 +++ 8 files changed, 26 insertions(+), 22 deletions(-) diff --git a/bindings/HeatPhase.ts b/bindings/HeatPhase.ts index d0abac6..2a61121 100644 --- a/bindings/HeatPhase.ts +++ b/bindings/HeatPhase.ts @@ -2,7 +2,7 @@ /** * The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged → - * Armed → Running → Landed → Scored`). + * Armed → Running → Finished → Scored`). * * This is the *projected* view of the heat loop — the folded current phase a client * renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the @@ -10,4 +10,4 @@ * one of these phases, so the live view stays a simple linear status. A #41-era detail * the placeholder pins minimally. */ -export type HeatPhase = "Scheduled" | "Staged" | "Armed" | "Running" | "Landed" | "Scored"; +export type HeatPhase = "Scheduled" | "Staged" | "Armed" | "Running" | "Finished" | "Scored"; diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 8134720..36ce7b5 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -179,7 +179,7 @@ fn phase_of(state: HeatState) -> HeatPhase { HeatState::Staged => HeatPhase::Staged, HeatState::Armed => HeatPhase::Armed, HeatState::Running => HeatPhase::Running, - HeatState::Finished => HeatPhase::Landed, + HeatState::Finished => HeatPhase::Finished, HeatState::Scored => HeatPhase::Scored, } } @@ -313,12 +313,12 @@ mod tests { #[test] fn phase_tracks_the_heat_loop_through_scored() { - // Scheduled → Staged → Armed → Running → Finished(Landed) → Scored. + // Scheduled → Staged → Armed → Running → Finished → Scored. let steps = [ (HeatTransition::Staged, HeatPhase::Staged), (HeatTransition::Armed, HeatPhase::Armed), (HeatTransition::Running, HeatPhase::Running), - (HeatTransition::Finished, HeatPhase::Landed), + (HeatTransition::Finished, HeatPhase::Finished), (HeatTransition::Scored, HeatPhase::Scored), ]; let mut events = vec![scheduled("q-1", &["A", "B"])]; diff --git a/crates/server/src/snapshot.rs b/crates/server/src/snapshot.rs index 0354114..0037639 100644 --- a/crates/server/src/snapshot.rs +++ b/crates/server/src/snapshot.rs @@ -40,7 +40,7 @@ use crate::stream::Cursor; pub use crate::live_state::{LiveRaceState, PilotProgress}; /// The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged → -/// Armed → Running → Landed → Scored`). +/// Armed → Running → Finished → Scored`). /// /// This is the *projected* view of the heat loop — the folded current phase a client /// renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the @@ -58,8 +58,9 @@ pub enum HeatPhase { Armed, /// The race is running; passes are being consumed. Running, - /// The race has closed — time elapsed or all landed — but is not yet scored. - Landed, + /// The race has closed — time elapsed or all finished — but is not yet scored. + /// Mirrors the canonical engine state `HeatState::Finished` (code-conventions §4). + Finished, /// The result is finalized. Scored, } diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index 1daf3eb..e309dd1 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -235,8 +235,8 @@ async fn control_ws_acks_each_command_and_rejects_illegal() { // rejected command): the next legal command (Finish) acks ok. let ack = send_command(&mut control, &Command::Finish { heat: heat() }).await; assert!(ack.ok, "finish after running should ack ok: {ack:?}"); - // The `Finished` transition lands in the `Landed` live-state phase. - assert_eq!(next_phase(&mut stream).await, HeatPhase::Landed); + // The `Finished` transition enters the `Finished` live-state phase. + assert_eq!(next_phase(&mut stream).await, HeatPhase::Finished); } /// A malformed control frame is answered with a failed ack, not a dropped socket: the next diff --git a/frontend/apps/rd-console/src/lib/transitions.ts b/frontend/apps/rd-console/src/lib/transitions.ts index 5a57d38..3e7beba 100644 --- a/frontend/apps/rd-console/src/lib/transitions.ts +++ b/frontend/apps/rd-console/src/lib/transitions.ts @@ -4,7 +4,7 @@ * The heat loop is a linear forward path with off-ramps (race-engine.html §2, * protocol.html §1): * - * Scheduled → Staged → Armed → Running → Landed → Scored → (Advanced) + * Scheduled → Staged → Armed → Running → Finished → Scored → (Advanced) * * `Command` exposes one variant per forward step (`Stage`/`Arm`/`Start`/`Finish`/ * `Score`/`Advance`) and three off-ramps (`Abort`/`Restart`/`Discard`). Each carries @@ -24,7 +24,7 @@ import type { Command, HeatId, HeatPhase } from '@gridfpv/types'; /** * The console-facing name of a heat-loop action. Mirrors the forward * `Command`/`HeatTransition` steps plus the three off-ramps. (`Start` enters - * `Running`; `Finish` enters the projected `Landed` phase; `Score` enters `Scored`.) + * `Running`; `Finish` enters the projected `Finished` phase; `Score` enters `Scored`.) */ export type HeatAction = | 'Stage' @@ -50,7 +50,7 @@ const PRIMARY_BY_PHASE: Record = { Staged: 'Arm', Armed: 'Start', Running: 'Finish', - Landed: 'Score', + Finished: 'Score', Scored: 'Advance' }; @@ -61,10 +61,10 @@ const PRIMARY_BY_PHASE: Record = { * sense: * • `Abort` — bail out of a heat that has been committed to but not yet scored * (Staged/Armed/Running): stop it where it is. - * • `Restart` — re-run from the top once committed (Staged/Armed/Running/Landed): + * • `Restart` — re-run from the top once committed (Staged/Armed/Running/Finished): * a bad start, a crash before the window, a contested run. * • `Discard` — throw the heat away entirely once it has results to throw away - * (Landed/Scored): it should never have counted. + * (Finished/Scored): it should never have counted. * * The engine is the final authority (it re-validates), so this errs toward the RD's * mental model rather than encoding every edge; an over-permissive entry simply @@ -75,7 +75,7 @@ const LEGAL_BY_PHASE: Record> = { Staged: new Set(['Arm', 'Abort', 'Restart']), Armed: new Set(['Start', 'Abort', 'Restart']), Running: new Set(['Finish', 'Abort', 'Restart']), - Landed: new Set(['Score', 'Restart', 'Discard']), + Finished: new Set(['Score', 'Restart', 'Discard']), Scored: new Set(['Advance', 'Discard']) }; diff --git a/frontend/apps/rd-console/tests/transitions.test.ts b/frontend/apps/rd-console/tests/transitions.test.ts index 84218ca..67652c3 100644 --- a/frontend/apps/rd-console/tests/transitions.test.ts +++ b/frontend/apps/rd-console/tests/transitions.test.ts @@ -10,7 +10,7 @@ import { type HeatAction } from '../src/lib/transitions.js'; -const PHASES: HeatPhase[] = ['Scheduled', 'Staged', 'Armed', 'Running', 'Landed', 'Scored']; +const PHASES: HeatPhase[] = ['Scheduled', 'Staged', 'Armed', 'Running', 'Finished', 'Scored']; describe('transitions: phase → legal actions', () => { it('maps each phase to its forward primary action', () => { @@ -18,7 +18,7 @@ describe('transitions: phase → legal actions', () => { expect(primaryAction('Staged')).toBe('Arm'); expect(primaryAction('Armed')).toBe('Start'); expect(primaryAction('Running')).toBe('Finish'); - expect(primaryAction('Landed')).toBe('Score'); + expect(primaryAction('Finished')).toBe('Score'); expect(primaryAction('Scored')).toBe('Advance'); }); @@ -36,8 +36,8 @@ describe('transitions: phase → legal actions', () => { expect(isActionLegal('Scheduled', 'Restart')).toBe(false); }); - it('allows discard only where there is a result to discard (Landed/Scored)', () => { - expect(isActionLegal('Landed', 'Discard')).toBe(true); + it('allows discard only where there is a result to discard (Finished/Scored)', () => { + expect(isActionLegal('Finished', 'Discard')).toBe(true); expect(isActionLegal('Scored', 'Discard')).toBe(true); expect(isActionLegal('Running', 'Discard')).toBe(false); }); diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index bb897af..e52daf9 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -228,9 +228,9 @@ describe('ProtocolClient', () => { expect(req.from).toBe(5); // The stream continues converged from 6. - sockets[1].emit(envelope(6n, 'Landed')); + sockets[1].emit(envelope(6n, 'Finished')); expect(client.getState().cursor).toBe(6n); - expect(phaseOf(client.getState().body)).toBe('Landed'); + expect(phaseOf(client.getState().body)).toBe('Finished'); client.close(); }); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 084d79b..b02ef96 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -171,6 +171,8 @@ fn live() -> bool { let zippyq_live = target("gridfpv-engine", "zippyq_live", true); let multiclass_live = target("gridfpv-engine", "multiclass_live", true); let full_event_live = target("gridfpv-engine", "full_event_live", true); + // The protocol server's mock-RH e2e: full event → server log → protocol client (#47). + let server_e2e = target("gridfpv-server", "full_event_live", true); ws && live_rh && signal && heat_live @@ -182,6 +184,7 @@ fn live() -> bool { && zippyq_live && multiclass_live && full_event_live + && server_e2e } fn main() { From 2a6145d3c4f93be6e57cbe32425eb727eb0aa1d9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:45:19 +0000 Subject: [PATCH 013/362] docs: reconcile v0.4 design docs with what was built (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring protocol.html, clients.html, and architecture.html from "to be decided" to "as built (v0.4)" so the design docs match the shipped protocol server + Svelte frontend. protocol.html - §1 heat loop: Landed → Finished (canonical engine name) - §3: note every envelope is a FreshValue (ProjectionBody); Change::Delta wired but deferred — stream correct-but-chatty - §4: soften scope-compose to one scope per subscribe as built - new "Endpoint surface (as built, v0.4)" table (health/snapshot/stream/control) - §9: append "As built (v0.4)" realized-fact to each of the 8 decisions - deferred-gaps callout: #60 registration binding, #61 event-setup commands, #62 race-start clock + splits, #57/#58 Tauri shell + 3-OS packaging not yet built (RD console ships as a web app) clients.html - mark Tauri shell + 3-OS single-binary packaging deferred (#57/#58) across lead, diagram, RD-console section, table row, and §6 - pin stack: Svelte 5 + Vite (no SvelteKit), npm-workspaces monorepo - test reality: vitest, 78 green tests (20 component / 7 protocol-client / 51 RD console) architecture.html - §6: replace with as-built note — gridfpv-server owns the wire types, ts-rs across events/projection/engine/server → bindings/, plus the CONTRACT_VERSION (u32) convention The crate-spine/ts-rs/contract-version edits targeted a docs/code-conventions.html that does not exist; their factual content was folded into architecture §6 where it belongs. Part of #59. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/architecture.html | 13 ++++++++ docs/clients.html | 43 +++++++++++++++++--------- docs/protocol.html | 70 +++++++++++++++++++++++++++++++++++------- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/docs/architecture.html b/docs/architecture.html index 1ff34ad..00b8d6c 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -321,6 +321,19 @@

6. One protocol, two transports

the server crate and are generated into TypeScript for the frontend, so the Director, the Cloud, and every client share one definition that can't drift.

+
+

+ As built (v0.4): the gridfpv-server crate owns the protocol + wire types (snapshot/stream/control/error/version), generated to TS in bindings/ + alongside the event model — one definition the Director, Cloud, and every client share. + Wire types are generated from Rust with ts-rs across the crates that own contract types — + gridfpv-events, gridfpv-projection, gridfpv-engine, + and gridfpv-server (the protocol contract) — exported to repo-root + bindings/. The protocol contract version is a single monotonic + u32 (CONTRACT_VERSION), bumped only on a breaking wire change; + additive changes an older client can ignore do not bump it. +

+

Exact message shapes (snapshot format, the change-stream envelope, auth) are a protocol spec of their own. The architectural commitments here are: read + diff --git a/docs/clients.html b/docs/clients.html index 2b2c9b0..6c352b4 100644 --- a/docs/clients.html +++ b/docs/clients.html @@ -16,7 +16,8 @@

Clients

Everything that renders GridFPV is a web client of the one protocol: - the RD console in the Tauri window, the racer/spectator PWA, the OBS overlays, and (future) + the RD console (destined for a Tauri window, shipped as a web app in v0.4), the + racer/spectator PWA, the OBS overlays, and (future) a privileged RD mobile-control surface on the LAN. They differ only in permissions, transport, and what they emphasize — not in how they talk to the Director. This doc settles the frontend stack those surfaces @@ -58,7 +59,7 @@

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 · authenticated<br/>web app (Tauri deferred)"] pwa["Racer / spectator PWA<br/>read · installable · push"] ovl["OBS overlays<br/>read-only · lean · browser source"] @@ -85,20 +86,24 @@

1. Three surfaces, one client

The RD console — control-oriented, dense, authenticated

- The race director's cockpit, loaded inside the Tauri window (Architecture - §2). It is the only surface that exercises the protocol's privileged write/control path — + The race director's cockpit, designed to load inside the Tauri window + (Architecture §2). The Tauri shell and 3-OS single-binary packaging are deferred + (#57/#58); v0.4 ships the RD console as a web app served by the Director. It is the + only surface that exercises the protocol's privileged write/control path — arming heats, marshaling, configuring the pipeline — so it authenticates with the RD role (Architecture §9; mechanics in Protocol). It is information-dense and keyboard-friendly: a working tool for someone running a live event, not a viewer.

- The console is "just a co-located web client" (Architecture §2): it loads over the - Tauri webview but talks to the same local axum server, over the same - protocol, as everyone else. The Tauri shell buys a friendly native window and OS - integration (file dialogs for export, single-binary packaging) — it does - not buy a private back channel. Control authority is a protocol role, - not a shell privilege. + The console is "just a co-located web client" (Architecture §2): once the Tauri shell + lands it will load over the Tauri webview, but either way it talks to the same local + axum server, over the same protocol, as everyone else. The Tauri shell will + buy a friendly native window and OS integration (file dialogs for export, single-binary + packaging) — it does not buy a private back channel. Control authority is + a protocol role, not a shell privilege. As built (v0.4): the Tauri shell and + single-binary packaging are deferred (#57/#58); the console is served as a web app by the + Director.

@@ -167,7 +172,7 @@

RD mobile-control surface — privileged, mobile, LAN-only (future) SurfaceDirectionAuthTransportShellEmphasis - RD consoleread + controlRD roleLAN (local)Tauridense, keyboard, working tool + RD consoleread + controlRD roleLAN (local)web app (Tauri deferred, #57/#58)dense, 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 @@ -208,6 +213,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 +274,10 @@

3. Shared component library & generated types

from Rust; the frontend consumes them. The component library is the only place UI lives, so every surface shows the same data the same way unless it deliberately chooses otherwise.

+

+ As built (v0.4): the frontend uses vitest; the suite is green (78 tests — 20 component, + 7 protocol-client, 51 RD console). +

4. PWA mechanics

@@ -358,10 +369,12 @@

5. Design system & UX

6. Native deferral via Capacitor

Native apps are deferred but kept cheap, by deliberate symmetry with the Director - (Architecture §7). The Director's RD console is a web UI wrapped in Tauri; - the racer/spectator PWA can later be wrapped in Capacitor to ship a native - app to the app stores. In both cases the web protocol-client is the constant, and - the native shell is an optional enhancement, not a rewrite. + (Architecture §7). The Director's RD console is a web UI destined to be wrapped in + Tauri (the shell and 3-OS single-binary packaging are deferred, #57/#58 — + v0.4 serves it as a web app); the racer/spectator PWA can later be wrapped in + Capacitor to ship a native app to the app stores. In both cases the + web protocol-client is the constant, and the native shell is an optional + enhancement, not a rewrite.

The web client is the constant on both ends. Tauri wraps the diff --git a/docs/protocol.html b/docs/protocol.html index 40c5f3a..066c05f 100644 --- a/docs/protocol.html +++ b/docs/protocol.html @@ -60,7 +60,7 @@

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

Live race-state
The current heat and its loop state (Scheduled → Staged → Armed → Running → - Landed → Scored, see Race Engine §2), the active + Finished → Scored, see Race Engine §2), the active pilots and their live lap/split progress, the running order, and the on-deck heat. The latency-sensitive core every overlay and spectator watches.
Lap lists
@@ -91,6 +91,16 @@

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

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

+
+

+ Deferred in v0.4. Several inputs the contract is designed around are not + yet wired: the registration-binding event (#60), + the event-setup commands (#61), and the LiveRaceState race-start clock plus + per-gate splits (#62) are still to come. And the Tauri shell + 3-OS single-binary + packaging are not yet built (#57/#58) — v0.4 ships the RD console as a web app + served by the Director, not as a native window. +

+

2. The snapshot read model

@@ -125,6 +135,25 @@

2. The snapshot read model

apart.

+

Endpoint surface (as built, v0.4)

+

+ The concrete HTTP/WS surface the Director serves today, behind the storage trait: +

+ + + + + + + + + + + + + +
EndpointWhat it serves
GET /healthliveness
GET /snapshot/event/{event}event snapshot
GET /snapshot/class/{event}/{class}class snapshot
GET /snapshot/heat/{heat} (+ ?projection=live|laps|result)heat snapshot
GET /snapshot/pilot/{event}/{pilot}pilot snapshot
GET /streamWS change stream (SubscribeRequestStreamMessage)
GET /control (WS) and POST /control (one-shot)RD-bearer-gated; CommandCommandAck
+

3. The realtime change stream

After the snapshot, the client opens a WebSocket and receives a stream @@ -140,6 +169,10 @@

3. The realtime change stream

local copy identical to the server's, and the server may collapse a burst into a single "this projection changed, here's its new value" envelope when a delta would be larger or ambiguous (e.g. after a marshaling correction that re-folds a whole lap list).

+

As built (v0.4) every envelope is a fresh-value carrying the projection's complete new + state (ProjectionBody); the incremental delta encodings are wired in the type + system (Change::Delta) but deferred, so the stream is correct-but-chatty until + they land.

Sequence, ordering & resume

@@ -240,8 +273,10 @@

4. Subscription scoping

heat.

- Scopes compose (a pilot within a class) and a client may hold several at once over one - connection. The three client kinds differ in scope and permission: + Scopes are designed to compose (a pilot within a class) and a client will eventually hold + several over one connection; as built (v0.4) a subscription addresses exactly one of the + four resources (event / class / heat / pilot). The three client kinds differ in scope + and permission:

@@ -400,44 +435,57 @@

9. Open decisions

regardless, so the contract ships WebSocket-only for every client. The change envelope stays transport-neutral, so an SSE read-only path can be added later only if a real spectator/overlay need appears — not by default, to avoid a second - code path. + code path. As built (v0.4): WS-only, no SSE — /stream for + reads and /control for the RD path.
  • RESOLVED (accepted as working design) — Change-envelope schema: delta vs fresh-value, per projection. The accepted approach is deltas for append-heavy projections (lap lists, live state) and fresh-value for cheap or re-folded ones (heat result, ranking after a correction), with a marshaling re-fold surfacing as a fresh-value envelope. - The exact envelope shapes are refined at implementation.
  • + The exact envelope shapes are refined at implementation. As built (v0.4): + the delta-vs-fresh split is wired in the type system but all envelopes are + FreshValue today; the delta encodings are deferred.
  • RESOLVED (accepted as working design) — Resume window & retention. The server retains a bounded window of replayable envelopes before forcing a re-snapshot: the Director is memory-bounded (one event) and the Cloud keeps a larger window (many events). Pairs with the storage tiers in Architecture §4. Exact window sizes are tuned at - implementation.
  • + implementation. As built (v0.4): the retained window is 256 log offsets; + an older cursor gets a ReSnapshotRequired / StaleCursor response.
  • RESOLVED — Auth credential format: opaque bearer tokens (+ optional signed join-token). RD and account auth use opaque bearer tokens backed by a server-side session (easy to revoke), plus an optional lightweight signed join-token (printed as a QR code, baked into the URL) granting read-only LAN access. Trust boundaries are fixed in Architecture §9. Revisit JWT only if - stateless Cloud scaling later demands it.
  • + stateless Cloud scaling later demands it. As built (v0.4): opaque 256-bit + bearer tokens in an in-memory revocable TokenStore, with roles Rd + and ReadOnly — reads are open, control is RD-gated, and the read-only join-token + grants read scope only.
  • RESOLVED (accepted as working design) — Sequence-cursor representation. The public projection sequence is a single monotonic integer per stream (not per-projection cursors a client composes). How it stays stable across a Director - restart / projection recompute is settled at implementation.
  • + restart / projection recompute is settled at implementation. As built (v0.4): + a per-stream u64Cursor counting from 1, distinct from the log + offset.
  • RESOLVED (accepted as working design) — Scope grammar & pagination. The contract uses a scope grammar for addressing (with multi-scope subscribe) and cursor-based pagination for large historical / open-data reads. The exact addressing scheme, URL shapes, and filter expressiveness are refined at - implementation.
  • + implementation. As built (v0.4): concrete REST scope addressing with one + scope per subscribe; the richer scope grammar and pagination are deferred.
  • RESOLVED (accepted as working design) — Contract version negotiation & support band. The version is carried in the connect message; the server serves a band of recent versions so an un-refreshed PWA keeps working, and emits an explicit "too old, please refresh" signal when a client falls outside it. Paired with log/schema versioning in Architecture §11. The - exact support-band depth is set at implementation.
  • + exact support-band depth is set at implementation. As built (v0.4): a + ContractVersion on the WS subscribe, a support band of 1..=1, and + a VersionMismatch refresh signal.
  • RESOLVED (accepted as working design) — Error model. The contract uses a single shared error shape across HTTP reads, the WS stream, and control acknowledgements — auth failure, unknown scope, stale-cursor, version-mismatch — defined once in Rust like the rest of the contract. The exact fields are pinned at - implementation.
  • + implementation. As built (v0.4): a single ProtocolError{code, + message} with six ErrorCode variants spanning HTTP, WS, and control.
    From e051fe309c2a64d830a6fa1b3c42d10e76a5dc0a Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 04:46:46 +0000 Subject: [PATCH 014/362] v0.4 doc reconciliation: code-conventions (server crate + ts-rs across crates + contract version) (#59) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/code-conventions.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/code-conventions.html b/docs/code-conventions.html index cf7e86e..a2f291e 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,15 @@

    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.

    4. Safety & the data model

    From 7253e8e7d6de3c9ce2e0804c32ee44ecb1cbee2e Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 11:59:01 +0000 Subject: [PATCH 015/362] Registration binding: CompetitorRegistered event + projection pilot-mapping (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RD registration screen binds a timer's local competitor (a CompetitorRef) to a named pilot, but gridfpv-events had no binding event, so the Register control command acked-failed. Add the logged binding plus the projection that surfaces pilot identity in live state and results. events: - Add `PilotId(pub String)` — the canonical, event-scoped pilot id (callsign / stable id), transparent serde + ts-rs newtype. - Add `Event::CompetitorRegistered { adapter, competitor, pilot }`: the logged binding "this timer channel IS this pilot" (Architecture §9). server: - Drop the duplicate `PilotId` from scope.rs; re-export the canonical `gridfpv_events::PilotId` so the wire/scope layer and the log share one type. - Wire `Command::Register` to validate-free append `Event::CompetitorRegistered` via `AppState::append`, acking ok (the deferral is gone). - live_state: surface the bound `PilotId` on `PilotProgress` (None when unregistered); pilot-scope snapshot resolves a PilotId to its bound competitor(s), falling back to the bare-ref match for unregistered events. projection: - Add `registrations(events) -> BTreeMap`: the per-source registration fold, last-registration-wins. bindings: regenerated (CompetitorRegistered, PilotId, PilotProgress.pilot). Part of #60. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/Event.ts | 3 +- bindings/PilotId.ts | 14 ++-- bindings/PilotProgress.ts | 7 ++ crates/events/src/lib.rs | 47 ++++++++++++++ crates/projection/src/lib.rs | 96 +++++++++++++++++++++++++++- crates/server/src/app.rs | 57 +++++++++++++++-- crates/server/src/control_handler.rs | 59 ++++++++++------- crates/server/src/live_state.rs | 67 ++++++++++++++++++- crates/server/src/scope.rs | 24 +++---- 9 files changed, 322 insertions(+), 52 deletions(-) diff --git a/bindings/Event.ts b/bindings/Event.ts index c3b206e..78bdaff 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -6,6 +6,7 @@ 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 { SessionId } from "./SessionId"; import type { SourceTime } from "./SourceTime"; @@ -21,4 +22,4 @@ 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 } | { "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, } }; diff --git a/bindings/PilotId.ts b/bindings/PilotId.ts index 63cfca9..9d78201 100644 --- a/bindings/PilotId.ts +++ b/bindings/PilotId.ts @@ -1,12 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer - * following their own laps, results, and next heat. + * 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 event-scoped pilot handle a scope addresses, distinct from the - * per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a - * pilot is bound to source competitors by a registration action (Architecture §9), - * which is out of scope here. + * 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 index 057a9c3..9264193 100644 --- a/bindings/PilotProgress.ts +++ b/bindings/PilotProgress.ts @@ -1,5 +1,6 @@ // 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). @@ -13,6 +14,12 @@ 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. */ diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 87d3b5a..8721747 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 @@ -236,6 +250,17 @@ 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), @@ -419,6 +444,28 @@ 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 diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 6812904..e338488 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -28,7 +28,7 @@ use std::collections::BTreeMap; -use gridfpv_events::{AdapterId, CompetitorRef, Event, Pass, SourceTime}; +use gridfpv_events::{AdapterId, CompetitorRef, Event, Pass, PilotId, SourceTime}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -114,6 +114,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; @@ -646,6 +681,65 @@ mod tests { ); } + #[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())) + ); + } + #[test] fn lap_list_serde_round_trips() { let events = vec![ diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 6173002..e3d7493 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -80,7 +80,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::get; use gridfpv_engine::scoring::{WinCondition, score_events}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; -use gridfpv_projection::{LapList, lap_list_marshaled}; +use gridfpv_projection::{LapList, lap_list_marshaled, registrations}; use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; use tokio::sync::Notify; @@ -378,21 +378,40 @@ async fn snapshot_heat( /// `GET /snapshot/pilot/{event}/{pilot}` — a pilot's laps across the event (§4 pilot scope). /// -/// Filters the lap projection to the competitor whose ref equals the `pilot` id (the -/// registration binding mapping a `PilotId` to source competitors is out of scope here — -/// see the module docs). +/// 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(state): State, Path((_event, pilot)): Path<(EventId, PilotId)>, ) -> Result, ProtocolError> { 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()); + 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) + .filter(|c| { + if bound.is_empty() { + c.competitor.competitor == fallback_ref + } else { + bound.contains(&c.competitor.competitor) + } + }) .collect(); if competitors.is_empty() { @@ -650,6 +669,32 @@ mod tests { } } + #[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 (state, _) = state_with(events); + let (status, snap) = get_snapshot(state, "/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 (state, _) = state_with(recorded_heat()); diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 90bafb3..b1ca148 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -16,7 +16,7 @@ //! |---------------|------------|----------------| //! | heat-loop (`Stage`/`Arm`/`Start`/`Finish`/`Score`/`Advance`/`Abort`/`Restart`/`Discard`) | [`heat::heat_state`] folds the heat's current state; [`heat::apply`] checks the transition is legal | [`Event::HeatStateChanged`] with the engine-returned [`HeatTransition`](gridfpv_events::HeatTransition) | //! | [`Command::ScheduleHeat`] | none (it creates the heat) | [`Event::HeatScheduled`] | -//! | [`Command::Register`] | — | **deferred** — see below | +//! | [`Command::Register`] | none (the binding is always recordable; last-registration-wins folds downstream) | [`Event::CompetitorRegistered`] | //! | [`Command::VoidDetection`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::DetectionVoided`] | //! | [`Command::AdjustLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::LapAdjusted`] | //! | [`Command::InsertLap`] | none (it adds a pass) | [`Event::LapInserted`] | @@ -34,16 +34,16 @@ //! scheduled (no `HeatScheduled` in the log, so `heat_state` is `None`) is rejected with //! [`ErrorCode::UnknownScope`] — nothing is appended. //! -//! ## Register is deferred (a model gap, not a protocol gap) +//! ## Register binds a competitor to a pilot (#60) //! -//! The event log (`gridfpv-events`) carries **no pilot-binding event** — there is a -//! [`CompetitorSeen`](gridfpv_events::Event::CompetitorSeen) *adapter observation*, but no -//! event that records "this source competitor *is* this event-scoped pilot" (Architecture -//! §9; the same gap the snapshot path notes for pilot scope). Rather than append a -//! lossy stand-in, [`Command::Register`] is acknowledged as **not yet modelled** with a -//! [`ProtocolError`] of [`ErrorCode::BadRequest`] that names the deferral, and **nothing -//! is appended**. When the registration event lands in the log model this becomes a -//! one-line append like the others; the command vocabulary and endpoint already carry it. +//! [`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) //! @@ -248,12 +248,16 @@ fn command_to_event(state: &AppState, command: Command) -> Result Ok(Event::HeatScheduled { heat, lineup }), - // --- Registration: no binding event in the log model yet (see module docs). --- - Command::Register { .. } => Err(ProtocolError::new( - ErrorCode::BadRequest, - "registration binding is not yet modelled in the event log \ - (Architecture §9; deferred — no pilot-binding event exists to append)", - )), + // --- 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, then append. --- Command::VoidDetection { target } => { @@ -545,22 +549,29 @@ mod tests { assert!(!ack.ok); } - /// `Register` is acknowledged as not-yet-modelled and appends nothing (the model gap). + /// `Register` acks ok and appends the `CompetitorRegistered` binding (#60). #[test] - fn register_is_deferred_and_appends_nothing() { + fn register_appends_competitor_registered_and_acks_ok() { + use gridfpv_events::PilotId; let state = scheduled_state(); - let (before, _) = state.read().unwrap(); let ack = apply_command( &state, Command::Register { adapter: AdapterId("rh".into()), competitor: CompetitorRef("node-2".into()), - pilot: crate::scope::PilotId("acroace".into()), + pilot: PilotId("acroace".into()), }, ); - assert!(!ack.ok); - assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); - let (after, _) = state.read().unwrap(); - assert_eq!(before.len(), after.len()); + 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()) + ))); } } diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 36ce7b5..3b04942 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -41,8 +41,8 @@ use std::collections::BTreeMap; use gridfpv_engine::heat::{HeatState, heat_state}; -use gridfpv_events::{CompetitorRef, Event, HeatId}; -use gridfpv_projection::{CompetitorKey, lap_list_marshaled}; +use gridfpv_events::{CompetitorRef, Event, HeatId, PilotId}; +use gridfpv_projection::{CompetitorKey, lap_list_marshaled, registrations}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -108,6 +108,12 @@ impl Default for LiveRaceState { 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 @@ -147,6 +153,17 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { entry.1 = cl.laps.last().map(|l| l.duration_micros).or(entry.1); } + // 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| { @@ -154,6 +171,7 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { by_ref.get(competitor).copied().unwrap_or((0, None)); PilotProgress { competitor: competitor.clone(), + pilot: pilot_by_ref.get(competitor).map(|p| (*p).clone()), laps_completed, last_lap_micros, } @@ -283,6 +301,14 @@ mod tests { }) } + 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()); @@ -426,6 +452,43 @@ mod tests { assert_eq!(s.on_deck, Some(HeatId("q-2".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![ diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs index 33cdb09..539eda1 100644 --- a/crates/server/src/scope.rs +++ b/crates/server/src/scope.rs @@ -17,6 +17,18 @@ 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 @@ -34,18 +46,6 @@ pub struct EventId(pub String); #[ts(export, export_to = "bindings/")] pub struct ClassId(pub String); -/// Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer -/// following their own laps, results, and next heat. -/// -/// This is the event-scoped pilot handle a scope addresses, distinct from the -/// per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a -/// pilot is bound to source competitors by a registration action (Architecture §9), -/// which is out of scope here. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] -#[serde(transparent)] -#[ts(export, export_to = "bindings/")] -pub struct PilotId(pub String); - /// What a subscription addresses (protocol.html §4): one of the four resources a /// client can scope to. Externally tagged like the event model, so it maps to a TS /// discriminated union. From 4d73c2fd046c56ac86b6baa2c63ece14259be4a1 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 12:00:53 +0000 Subject: [PATCH 016/362] Make the gridfpv binary a real Director server (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the v0.1 walking-skeleton `gridfpv` binary into the Director server: it opens the one append-only event log, builds the protocol read/realtime + control API (`server::app::router`), serves the built RD console as a static SPA, and mints + prints an RD token so the RD can log in. - `crates/app/src/director.rs`: `Config::from_env` (GRIDFPV_ADDR default 0.0.0.0:8080 / GRIDFPV_DB default in-memory SQLite, a path opens a file log / GRIDFPV_ASSETS default the workspace's frontend/apps/rd-console/dist) and `build_app`, which mounts the protocol router, falls back to a ServeDir over the assets with an index.html SPA fallback (deep client routes resolve to the shell, a clear 404 when unbuilt), and layers permissive CORS so the cross-origin Tauri RD app can call the API + open the WS. - `main.rs`: async tokio entry that wires the log -> AppState -> router, binds GRIDFPV_ADDR, prints the URL + RD token + log/asset status, and serves with graceful Ctrl-C shutdown. The old demo is kept as `gridfpv demo`; `lib.rs` and its tests are untouched. - `tests/director.rs`: drives the exact served router over an InMemoryLog via `oneshot` — health 200, snapshot 200, SPA index + deep-route fallback from a temp assets dir, static asset serving, and a permissive CORS preflight. The committed tests never depend on the real frontend dist. Deps added to crates/app: gridfpv-server, axum 0.8, tokio (rt-multi-thread), tower-http (fs/cors/trace). No adapters / `live` feature, so the Director binary pulls no openssl. `cargo xtask ci` is green. The live-timer pipeline (RH adapter -> engine -> AppState::append) is a separate later step and is intentionally not wired here. Part of #13 (v0.4 Director wiring). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 49 ++++++++++ crates/app/Cargo.toml | 16 ++++ crates/app/src/director.rs | 179 +++++++++++++++++++++++++++++++++++ crates/app/src/lib.rs | 8 ++ crates/app/src/main.rs | 113 +++++++++++++++++++++- crates/app/tests/director.rs | 156 ++++++++++++++++++++++++++++++ 6 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 crates/app/src/director.rs create mode 100644 crates/app/tests/director.rs diff --git a/Cargo.lock b/Cargo.lock index 570148f..21d52a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,11 +453,17 @@ dependencies = [ name = "gridfpv-app" version = "0.1.0" dependencies = [ + "axum", "gridfpv-adapters", "gridfpv-events", "gridfpv-projection", + "gridfpv-server", "gridfpv-storage", + "http-body-util", "serde_json", + "tokio", + "tower", + "tower-http", ] [[package]] @@ -608,6 +614,12 @@ 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" @@ -895,6 +907,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" @@ -1397,6 +1419,16 @@ 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" @@ -1577,6 +1609,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1688,13 +1721,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", ] @@ -1818,6 +1861,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" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index ad3b467..3ef35bb 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -15,6 +15,22 @@ gridfpv-events.workspace = true gridfpv-storage.workspace = true gridfpv-projection.workspace = 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"] } + [dev-dependencies] serde_json.workspace = true gridfpv-adapters.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..c1eead3 --- /dev/null +++ b/crates/app/src/director.rs @@ -0,0 +1,179 @@ +//! 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::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use axum::Router; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use gridfpv_server::app::{AppState, router}; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; + +/// 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 the durable event log lives (`GRIDFPV_DB`): `None` ⇒ an in-memory SQLite log + /// (nothing persisted; fresh every start), `Some(path)` ⇒ open/create a file log there. + pub db: 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_DB` — SQLite log path; unset ⇒ in-memory (non-durable). + /// - `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 db = match std::env::var("GRIDFPV_DB") { + Ok(value) if !value.trim().is_empty() => Some(PathBuf::from(value)), + _ => 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, db, assets }) + } +} + +/// 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. 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`). +pub fn build_app(state: AppState, assets: &Path) -> Router { + // 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"); + let serve_dir = ServeDir::new(assets).fallback(spa_fallback(index_html)); + + router(state) + // Anything the protocol router does not handle falls through to the SPA. + .fallback_service(serve_dir) + // Permissive CORS so the cross-origin Tauri RD app can reach the API + WS. + .layer(CorsLayer::permissive()) +} + +/// 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. +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. +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() +} + +/// 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; diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 628875d..8c54c08 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -8,8 +8,16 @@ //! 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; + use gridfpv_events::{AdapterId, CompetitorRef, Event, GateIndex, Pass, SourceTime}; use gridfpv_projection::{LapList, lap_list}; use gridfpv_storage::{EventLog, Result as StorageResult}; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8426d84..8f78f8a 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,14 +1,119 @@ -//! 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::{AssetStatus, Config, asset_status, build_app}; use gridfpv_app::{SyntheticPilot, append_and_project, render_lap_list, synthetic_session}; +use gridfpv_server::app::AppState; 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. +async fn serve() -> Result<(), Box> { + let config = Config::from_env()?; + + // Open the event log: a file-backed SQLite log when `GRIDFPV_DB` is a path, else an + // in-memory one (non-durable, fresh each start). Either way it is the one append-only + // log every protocol path shares. + let state = match &config.db { + Some(path) => { + let log = SqliteLog::open(path)?; + AppState::new(log) + } + None => AppState::new(SqliteLog::open_in_memory()?), + }; + + // Mint the RD's control token up front and print it with the URL so the RD console (or + // the Tauri app) can log straight in. The store is in-memory, so this is the token for + // *this* run of the process. + let rd_token = state.tokens().issue_rd_token(); + + let app = build_app(state, &config.assets); + + let listener = tokio::net::TcpListener::bind(config.addr).await?; + let bound = listener.local_addr()?; + print_startup(&config, bound, &rd_token); + + // Serve until Ctrl-C; the protocol API + the RD console SPA are live on `bound`. + axum::serve(listener, app) + .with_graceful_shutdown(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. +fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &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}/"); + println!(" RD token : {rd_token}"); + println!(" (use as `Authorization: Bearer {rd_token}` on the control path)"); + match &config.db { + Some(path) => println!(" event log : sqlite file {}", path.display()), + None => { + println!(" event log : in-memory sqlite (non-durable — set GRIDFPV_DB to persist)") + } + } + 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/tests/director.rs b/crates/app/tests/director.rs new file mode 100644 index 0000000..eab34ac --- /dev/null +++ b/crates/app/tests/director.rs @@ -0,0 +1,156 @@ +//! 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::path::{Path, PathBuf}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use gridfpv_app::director::{AssetStatus, asset_status, build_app, default_assets_dir}; +use gridfpv_server::app::AppState; +use gridfpv_storage::InMemoryLog; +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 state = AppState::new(InMemoryLog::new()); + let app = build_app(state, 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, "/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 state = AppState::new(InMemoryLog::new()); + let app = build_app(state, &dir); + let response = app + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/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(); +} + +#[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() + ); +} From 8f5d78196a50e75a142fbb6a321474fc4e5fd8a8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 12:14:01 +0000 Subject: [PATCH 017/362] =?UTF-8?q?Add=20built-in=20sim=20lap=20source=20+?= =?UTF-8?q?=20control=E2=86=92source=20bridge=20to=20the=20Director=20(#13?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a full race run from the RD console with zero external deps: no Docker, no hardware, no second process. When the RD drives a heat to Running through the control path, the Director now generates synthetic lap-gate passes for that heat's lineup over real time, appends them to the one append-only log (so the live state updates and the console animates), and stops when the heat Finishes/Aborts/Scores. New `gridfpv_app::source` module: - `LapSource` seam: a source is told a heat went Running with a known lineup and emits lap-gate passes for it over time, pushing each through a `PassSink` that appends via `AppState::append`. The bridge owns the timer + cancellation + log; a source owns only what-to-emit and when. A feature-gated RotorHazard source slots in behind this same trait later (translating RH lap callbacks into `PassSink::emit`) without changing the bridge — the default Director stays openssl-free. - `SimSource`: per pilot a holeshot + N laps at a real-time pace, with mild deterministic per-pilot variation (slower per seed) so the running order is decided, not a tie. `Pass.at` is race-relative (ms since race start, like RH). - The bridge observes transitions by polling the log tail (the `/stream` Notify is pub(crate) to the server crate) over an Offset cursor on a 150ms interval: on Running it looks up the heat's lineup and spawns the source task; on Finished/Aborted/Scored/Restarted for the running heat — or a newer heat going Running — it aborts the task. At most one heat emits at a time. Config (env, defaulted, printed on startup): GRIDFPV_SOURCE (default `sim`; `rh:` reserved — logs "unsupported, using sim" today), GRIDFPV_SIM_LAPS (default 5), GRIDFPV_SIM_LAP_MS (default 2500ms). Tests run headless and fast over an InMemoryLog + AppState with a tiny (1–3ms) lap pace and condition-polling (no fixed multi-second sleeps): assert a holeshot+laps pass per lineup member, that live_state/PilotProgress shows laps_completed > 0, and that no further passes land after Finished. `cargo xtask ci` is green and `gridfpv-app`'s non-dev dep tree is openssl-free. Part of #13 (v0.4 Director wiring). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/lib.rs | 1 + crates/app/src/main.rs | 19 +- crates/app/src/source.rs | 725 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 crates/app/src/source.rs diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 8c54c08..565570c 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -17,6 +17,7 @@ #![forbid(unsafe_code)] pub mod director; +pub mod source; use gridfpv_events::{AdapterId, CompetitorRef, Event, GateIndex, Pass, SourceTime}; use gridfpv_projection::{LapList, lap_list}; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8f78f8a..ac52dd0 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -15,7 +15,9 @@ #![forbid(unsafe_code)] use gridfpv_app::director::{AssetStatus, Config, asset_status, build_app}; +use gridfpv_app::source::{SIM_ADAPTER, SourceConfig, spawn_bridge}; use gridfpv_app::{SyntheticPilot, append_and_project, render_lap_list, synthetic_session}; +use gridfpv_events::AdapterId; use gridfpv_server::app::AppState; use gridfpv_storage::SqliteLog; @@ -52,11 +54,19 @@ async fn serve() -> Result<(), Box> { // *this* run of the process. let rd_token = state.tokens().issue_rd_token(); + // Resolve the built-in lap source (default `sim`) and spawn the control→source bridge + // alongside the server: it shares this same `AppState`/log, watches for heats driven to + // `Running` through the control path, and appends synthetic laps for them in real time + // (see [`gridfpv_app::source`]). It runs until the process exits. + let source = SourceConfig::from_env(); + let source_desc = source.describe(); + let _bridge = spawn_bridge(state.clone(), source, AdapterId(SIM_ADAPTER.to_string())); + let app = build_app(state, &config.assets); let listener = tokio::net::TcpListener::bind(config.addr).await?; let bound = listener.local_addr()?; - print_startup(&config, bound, &rd_token); + print_startup(&config, bound, &rd_token, &source_desc); // Serve until Ctrl-C; the protocol API + the RD console SPA are live on `bound`. axum::serve(listener, app) @@ -69,7 +79,7 @@ async fn serve() -> Result<(), Box> { /// 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. -fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &str) { +fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &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()) @@ -88,6 +98,11 @@ fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &str) { println!(" event log : in-memory sqlite (non-durable — set GRIDFPV_DB 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)" + ); let assets = config.assets.display(); match asset_status(&config.assets) { AssetStatus::Built => println!(" RD console : serving SPA from {assets}"), diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs new file mode 100644 index 0000000..b3d43e0 --- /dev/null +++ b/crates/app/src/source.rs @@ -0,0 +1,725 @@ +//! 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`/`Scored`/`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::sync::Arc; +use std::time::Duration; + +use gridfpv_events::{ + AdapterId, CompetitorRef, Event, GateIndex, HeatId, HeatTransition, Pass, SourceTime, +}; +use gridfpv_server::app::AppState; +use gridfpv_storage::Offset; +use tokio::task::JoinHandle; + +/// 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 append surface a [`LapSource`] pushes passes through. Wraps the shared +/// [`AppState`] so every emitted pass lands in the one log and wakes `/stream`. +#[derive(Clone)] +pub struct PassSink { + state: AppState, + adapter: AdapterId, +} + +impl PassSink { + /// A sink over `state` tagging passes with `adapter`. + pub fn new(state: AppState, adapter: AdapterId) -> Self { + Self { state, adapter } + } + + /// 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> { + let pass = Event::Pass(Pass { + adapter: self.adapter.clone(), + competitor: competitor.clone(), + at, + sequence: Some(sequence), + gate: GateIndex::LAP, + signal: None, + }); + self.state + .append(pass, None) + .map_err(|e| SourceError(format!("{e:?}")))?; + Ok(()) + } +} + +/// 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() + ), + } + } + + /// Materialise the boxed [`LapSource`] the bridge drives. + fn into_source(self) -> Arc { + match self { + SourceConfig::Sim(sim) => Arc::new(sim), + } + } +} + +/// Spawn the control→source bridge as a background task, returning its [`JoinHandle`]. +/// +/// The task polls the log tail (see the module docs) until `state`'s log handle is dropped +/// at shutdown; the Director spawns it alongside `axum::serve` and lets it run for the +/// process lifetime. `adapter` is the [`AdapterId`] emitted passes carry. +pub fn spawn_bridge(state: AppState, source: SourceConfig, adapter: AdapterId) -> JoinHandle<()> { + let source = source.into_source(); + tokio::spawn(async move { + run_bridge(state, source, adapter).await; + }) +} + +/// The bridge loop: poll the log tail, drive the source on `Running`, cancel on +/// `Finished`/`Aborted`/`Scored`/`Restarted` (or a newer heat going `Running`). +/// +/// Exposed (crate-internal) so the test harness can run it directly against an in-memory +/// [`AppState`] and assert on the appended passes. +pub(crate) async fn run_bridge(state: AppState, source: Arc, adapter: AdapterId) { + let mut cursor: Offset = 0; + // The in-flight heat task, if a heat is currently emitting. At most one at a time. + let mut active: Option = None; + let mut ticker = tokio::time::interval(POLL_INTERVAL); + + loop { + ticker.tick().await; + + // 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). + if let Some(running) = &active { + if running.handle.is_finished() { + active = None; + } + } + + let new_events = match read_tail(&state, cursor) { + Ok(batch) => batch, + // A poisoned lock (or a dropped log at shutdown) ends the bridge cleanly. + Err(_) => return, + }; + 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, &source, &adapter, &mut active, heat, transition); + } + } + } +} + +/// React to a heat-loop transition: start emitting on `Running`, stop on a terminal / +/// off-ramp transition for the heat that is currently emitting. +fn handle_transition( + state: &AppState, + source: &Arc, + adapter: &AdapterId, + active: &mut Option, + heat: HeatId, + transition: HeatTransition, +) { + 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. + if let Some(running) = active.take() { + running.handle.abort(); + } + 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; + } + let sink = PassSink::new(state.clone(), adapter.clone()); + let run = HeatRun { + heat: heat.clone(), + lineup, + }; + let source = Arc::clone(source); + let handle = tokio::spawn(async move { + if let Err(e) = source.run_heat(run, sink).await { + eprintln!("gridfpv: sim source stopped: {e}"); + } + }); + *active = Some(ActiveHeat { heat, handle }); + } + // 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::Scored + | HeatTransition::Restarted + | HeatTransition::Discarded + | HeatTransition::Advanced => { + if let Some(running) = active.as_ref() { + if running.heat == heat { + if let Some(running) = active.take() { + running.handle.abort(); + } + } + } + } + // Staged/Armed are pre-Running: nothing to cancel (the heat isn't emitting yet). + HeatTransition::Staged | HeatTransition::Armed => {} + } +} + +/// A heat currently emitting passes: which heat, and the task driving its source. +struct ActiveHeat { + heat: HeatId, + handle: JoinHandle<()>, +} + +/// 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. +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 +} + +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_server::live_state::live_state; + use gridfpv_storage::InMemoryLog; + use tokio::time::{Instant, sleep, timeout}; + + /// Build an `AppState` over a fresh in-memory log. + fn fresh_state() -> AppState { + AppState::new(InMemoryLog::new()) + } + + /// A fast sim source so the whole heat runs in a few ms (no seconds-long sleeps): a 1ms + /// lap pace. The bridge polls at [`POLL_INTERVAL`], which dominates start-up latency, so + /// we keep total laps small. + fn fast_source(laps: u32) -> Arc { + Arc::new(SimSource::new(laps, Duration::from_millis(1))) + } + + /// Drive the bridge in the background over `state`, returning its abort handle. Uses a + /// fast source so emission completes quickly. + fn spawn_test_bridge(state: AppState, laps: u32) -> JoinHandle<()> { + let source = fast_source(laps); + let adapter = AdapterId(SIM_ADAPTER.to_string()); + tokio::spawn(async move { run_bridge(state, source, adapter).await }) + } + + 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 state = fresh_state(); + let laps = 3u32; + let bridge = spawn_test_bridge(state.clone(), laps); + + // 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())], + }, + 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() { + let state = fresh_state(); + // 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 source: Arc = Arc::new(SimSource::new(50, Duration::from_millis(3))); + let adapter = AdapterId(SIM_ADAPTER.to_string()); + let bridge = { + let state = state.clone(); + tokio::spawn(async move { run_bridge(state, source, adapter).await }) + }; + + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + }, + 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 state = fresh_state(); + let bridge = spawn_test_bridge(state.clone(), 40); + + // Heat 1 starts running. + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + }, + 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())], + }, + 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(); + } + + #[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)); + } +} From 79a1fbbf8f4df693138ec1ed8e15b0b40b2d5c76 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 13:31:40 +0000 Subject: [PATCH 018/362] fix(protocol-client): order the stream by per-stream sequence, not the snapshot cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot `cursor` is a log offset (the `from:` resume point); the WS change stream's `sequence` is a separate axis that restarts at 1 per subscription (protocol.html §3/§9.5). `applyEnvelope` compared the incoming sequence against the snapshot cursor, so against any non-empty log (cursor > 0 — every real Director snapshot) the early stream was dropped as bogus "duplicates" and the live view froze. The unit tests masked it by only ever using cursor 0n. Track a separate `streamSeq` (reset to 0 on each subscribe) for ordering/dedup/ gap-detection; keep `cursor` as the resume offset. Lead test now uses a non-zero snapshot cursor so it actually guards the freeze; the three tests that asserted `cursor` advancing with the stream sequence are corrected to the real contract. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../protocol-client/src/client.test.ts | 35 +++++++++++++------ .../packages/protocol-client/src/client.ts | 34 ++++++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index e52daf9..3146161 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -164,8 +164,13 @@ describe('ProtocolClient', () => { client.close(); }); - it('applies an ordered change stream in sequence and stays converged', async () => { - const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + it('applies an ordered change stream regardless of the snapshot cursor value', async () => { + // Regression (the freeze): the snapshot cursor is a log OFFSET (here 5), while the + // stream's `sequence` is its own axis starting at 1. The client must order the + // stream by `sequence` — conflating it with the cursor (`seq <= 5` → "duplicate") + // dropped the early stream and froze the live view against any non-empty log, + // which is every real Director snapshot. Earlier tests used cursor 0n, masking it. + const { fetch } = mockFetch([{ cursor: 5n, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); await flush(); @@ -175,8 +180,10 @@ describe('ProtocolClient', () => { sockets[0].emit(envelope(2n, 'Armed')); sockets[0].emit(envelope(3n, 'Running')); - expect(client.getState().cursor).toBe(3n); + // Every envelope applied → body converged. The resume cursor stays the snapshot + // offset (the stream sequence is not a log offset). expect(phaseOf(client.getState().body)).toBe('Running'); + expect(client.getState().cursor).toBe(5n); client.close(); }); @@ -190,11 +197,11 @@ describe('ProtocolClient', () => { sockets[0].emit(envelope(1n, 'Staged')); sockets[0].emit(envelope(2n, 'Armed')); - // Re-deliver 1 and 2 (at-least-once): they must not regress the state. + // Re-deliver 1 and 2 (at-least-once): they're at/below the last applied sequence + // (2), so they're no-ops and must not regress the state back to 'Scheduled'. sockets[0].emit(envelope(1n, 'Scheduled')); sockets[0].emit(envelope(2n, 'Scheduled')); - expect(client.getState().cursor).toBe(2n); expect(phaseOf(client.getState().body)).toBe('Armed'); client.close(); @@ -227,10 +234,12 @@ describe('ProtocolClient', () => { const req = JSON.parse(sockets[1].sent[0]); expect(req.from).toBe(5); - // The stream continues converged from 6. + // The fresh subscription restarts the per-stream sequence, so its first envelope + // is accepted and the body converges. The resume cursor stays the re-snapshot + // offset (5). sockets[1].emit(envelope(6n, 'Finished')); - expect(client.getState().cursor).toBe(6n); expect(phaseOf(client.getState().body)).toBe('Finished'); + expect(client.getState().cursor).toBe(5n); client.close(); }); @@ -287,12 +296,16 @@ describe('ProtocolClient', () => { sockets[1].open(); const req = JSON.parse(sockets[1].sent[0]); - expect(req.from).toBe(2); + // Resume from the snapshot's log offset (0) — NOT the stream sequence (which is a + // different axis). The server replays from there and fresh-value envelopes + // re-converge. (A future enhancement: carry the log offset on envelopes so the + // resume point can advance; until then resume re-replays from the snapshot.) + expect(req.from).toBe(0); expect(client.getState().status).toBe('live'); - // Stream continues converged. - sockets[1].emit(envelope(3n, 'Running')); - expect(client.getState().cursor).toBe(3n); + // The resumed subscription restarts the sequence; its first envelope converges. + sockets[1].emit(envelope(1n, 'Running')); + expect(phaseOf(client.getState().body)).toBe('Running'); client.close(); }); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 01598aa..2d6a7de 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -202,7 +202,13 @@ export function connect(options: ConnectOptions): ProtocolClient { // ── Mutable connection state ─────────────────────────────────────────────── let body: ProjectionBody | undefined; + // The snapshot `cursor` is a log offset (protocol.html §2) used ONLY as the `from:` + // resume point — it is not the stream's ordering counter. let cursor: Cursor | undefined; + // The per-stream `sequence` axis (protocol.html §3/§9.5): starts at 1 on each + // subscription, distinct from `cursor`. Reset to 0 on every (re)subscribe so the + // first envelope is accepted whatever the snapshot cursor's value. + let streamSeq: Cursor = 0n; let status: ConnectionStatus = 'connecting'; let lastError: ProtocolError | undefined; @@ -273,30 +279,38 @@ export function connect(options: ConnectOptions): ProtocolClient { // (missed envelopes → caller must re-snapshot). function applyEnvelope(env: ChangeEnvelope): 'applied' | 'duplicate' | 'gap' { const seq = env.sequence; - if (cursor !== undefined) { - // Idempotent, keyed by sequence: anything at or below the cursor is a no-op. - if (seq <= cursor) return 'duplicate'; - // The stream is contiguous: the next envelope must be exactly cursor + 1. - if (seq !== cursor + 1n) return 'gap'; + // Order + dedup against the per-stream `sequence` axis — NOT the snapshot + // `cursor` (a log offset). The two are distinct monotonic counters + // (protocol.html §3/§9.5); conflating them drops the early stream as bogus + // "duplicates" and freezes the live view. `streamSeq === 0` means this is the + // first envelope of a fresh subscription, so accept it whatever its value. + if (streamSeq !== 0n) { + // Idempotent, keyed by sequence: anything at or below the last applied is a no-op. + if (seq <= streamSeq) return 'duplicate'; + // The stream is contiguous: the next envelope must be exactly +1. + if (seq !== streamSeq + 1n) return 'gap'; } const change = env.change; if ('FreshValue' in change) { body = change.FreshValue; } else { // Delta. The per-projection delta encodings are deferred (#43): the wire - // type carries an opaque payload today. We advance the cursor so ordering - // and resume stay correct; once #43 pins the typed deltas, fold them into - // `body` here per ProjectionKind. Until then a delta cannot mutate `body`, - // and a re-snapshot (always correct, §3) reconciles any drift. + // type carries an opaque payload today. We advance the sequence so ordering + // and gap-detection stay correct; once #43 pins the typed deltas, fold them + // into `body` here per ProjectionKind. Until then a delta cannot mutate + // `body`, and a re-snapshot (always correct, §3) reconciles any drift. void change.Delta; } - cursor = seq; + streamSeq = seq; return 'applied'; } // ── WebSocket subscribe + stream (protocol.html §3) ───────────────────────── function openSocket(gen: number): void { if (gen !== generation || closed) return; + // A fresh subscription: the server restarts the per-stream sequence at 1, so + // reset our tracker to accept it from the top. + streamSeq = 0n; setStatus('subscribing'); const url = token ? `${wsBase}/stream?token=${encodeURIComponent(token)}` : `${wsBase}/stream`; let socket: WebSocketLike; From 1db34d7629f0aba14c8dcdfc8db33d0d5b76d6a9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 13:42:49 +0000 Subject: [PATCH 019/362] Salvage the NewHeat flow + complete the console race path (v0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the stopped console-e2e agent, keep the genuinely-good parts and drop the reactivity spiral: - NewHeat.svelte + heat.ts: define pilots directly (the sim emits no CompetitorSeen), emitting Register per pilot + one ScheduleHeat; embedded in the live-control screen. - Session: add fetchHeatResult() (scored HeatResult is a separate ?projection=result read; the stream only carries LiveRaceState) + the heatResult field; switch the immutable projection bodies (protocolState, liveState, heatResult) to $state.raw — they're whole values replaced wholesale, so deep-proxying them is wasteful and a re-render footgun. - LiveRaceControl: render NewHeat; on Score, pull the heat result. - Results: read session.heatResult. The agent's session/client thrash is discarded; the real freeze was the protocol-client stream/cursor bug fixed separately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 7 +- frontend/apps/rd-console/src/lib/heat.ts | 91 +++++++++ .../apps/rd-console/src/lib/session.svelte.ts | 64 ++++++- .../src/screens/LiveRaceControl.svelte | 11 +- .../rd-console/src/screens/NewHeat.svelte | 179 ++++++++++++++++++ .../apps/rd-console/tests/NewHeat.test.ts | 54 ++++++ frontend/apps/rd-console/tests/heat.test.ts | 41 ++++ 7 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/heat.ts create mode 100644 frontend/apps/rd-console/src/screens/NewHeat.svelte create mode 100644 frontend/apps/rd-console/tests/NewHeat.test.ts create mode 100644 frontend/apps/rd-console/tests/heat.test.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index ef23aa8..8ff1126 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -112,9 +112,10 @@ {:else if active === 'results'} , pilot: }` per pilot — binds the + * source-local ref to a pilot so the projection can name it (the adapter never does + * this itself); + * 2. a single `ScheduleHeat { heat, lineup: [, …] }` — lays the heat down with the + * named field as its lineup. + * + * The RD then drives the heat loop (Stage → Arm → Start → …) from the live screen; on + * `Running` the sim flies passes for exactly this lineup. + */ + +import type { AdapterId, Command, CompetitorRef, HeatId } from '@gridfpv/types'; +import { registerCommand } from './registration.js'; +import { scheduleHeatCommand } from './setup.js'; + +/** The default adapter a sim heat's competitors are bound under (the built-in source). */ +export const SIM_ADAPTER: AdapterId = 'sim'; + +/** One pilot in a defined heat: the name the RD typed (used as both ref and pilot id). */ +export interface HeatPilot { + /** The pilot/competitor name (becomes the source-local ref *and* the pilot id). */ + name: string; +} + +/** A heat the RD has defined directly: an id and an ordered, named field. */ +export interface HeatDefinition { + /** The heat id the RD names (the handle the control path drives). */ + heat: HeatId; + /** The lineup, in seeding order — each pilot's typed name. */ + pilots: HeatPilot[]; + /** The adapter the competitors are bound under (defaults to {@link SIM_ADAPTER}). */ + adapter?: AdapterId; +} + +/** Trim and drop blank pilot names, preserving order. */ +function cleanNames(pilots: HeatPilot[]): string[] { + return pilots.map((p) => p.name.trim()).filter((n) => n.length > 0); +} + +/** + * Validate a heat definition — the form's "you can schedule it" gate. Returns the list of + * human problems (empty ⇒ ready). A heat needs an id, at least two distinct named pilots + * (a race is more than one entrant), and no duplicate names (refs must be unique). + */ +export function validateHeat(def: HeatDefinition): string[] { + const problems: string[] = []; + if (!def.heat.trim()) problems.push('Heat needs an id.'); + const names = cleanNames(def.pilots); + if (names.length < 2) problems.push('Add at least two pilots.'); + const seen = new Set(); + for (const n of names) { + if (seen.has(n)) problems.push(`Duplicate pilot name "${n}".`); + seen.add(n); + } + return problems; +} + +/** Is the definition complete enough to schedule? */ +export function isHeatValid(def: HeatDefinition): boolean { + return validateHeat(def).length === 0; +} + +/** The lineup (`CompetitorRef[]`) a definition schedules — the cleaned, ordered names. */ +export function lineupOf(def: HeatDefinition): CompetitorRef[] { + return cleanNames(def.pilots); +} + +/** + * Build the ordered command list that defines and schedules a heat from named pilots: one + * `Register` per pilot (binding `ref → pilot`, both the typed name) then one `ScheduleHeat` + * carrying the lineup. The caller sends these in order; the sim flies this lineup once the + * heat is driven to `Running`. + */ +export function defineHeatCommands(def: HeatDefinition): Command[] { + const adapter = def.adapter ?? SIM_ADAPTER; + const lineup = lineupOf(def); + const commands: Command[] = lineup.map((name) => registerCommand(adapter, name, name)); + commands.push(scheduleHeatCommand(def.heat, lineup)); + return commands; +} diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 8446f62..6325e50 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -19,7 +19,15 @@ import { connect } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; import { createControlClient } from './control.js'; import type { ControlClient } from './control.js'; -import type { Command, CommandAck, LiveRaceState, ProjectionBody, Scope } from '@gridfpv/types'; +import type { + Command, + CommandAck, + HeatId, + HeatResult, + LiveRaceState, + ProjectionBody, + Scope +} from '@gridfpv/types'; const STORAGE_KEY = 'gridfpv.rd.session'; @@ -65,10 +73,21 @@ export class Session { baseUrl = $state(''); /** The live read-client's lifecycle status (connecting → live → …). */ connectionStatus = $state('idle'); - /** The latest full protocol state (body + cursor + status + error). */ - protocolState = $state(undefined); - /** The current `LiveRaceState`, when the live scope is connected. */ - liveState = $state(undefined); + /** + * The latest full protocol state (body + cursor + status + error). `$state.raw`: + * the body is an immutable whole value replaced wholesale on every stream update, + * so we reassign rather than deep-proxy it — deep `$state` proxying a large + * projection body is wasteful and a re-render footgun for external immutable data. + */ + protocolState = $state.raw(undefined); + /** The current `LiveRaceState`, when the live scope is connected (immutable whole). */ + liveState = $state.raw(undefined); + /** + * The latest scored heat result the RD pulled via {@link fetchHeatResult}. The live + * read stream only carries `LiveRaceState`; a scored `HeatResult` is a separate, + * tighter heat-scope read (`?projection=result`), so the Results screen reads this. + */ + heatResult = $state.raw(undefined); /** The last control-path error surfaced to the RD (cleared on the next send). */ lastCommandError = $state(undefined); @@ -144,6 +163,7 @@ export class Session { this.connectionStatus = 'idle'; this.protocolState = undefined; this.liveState = undefined; + this.heatResult = undefined; this.lastCommandError = undefined; if (clearStorage) persist(null); } @@ -170,4 +190,38 @@ export class Session { clearCommandError(): void { this.lastCommandError = undefined; } + + /** + * Pull a heat's scored result (`GET /snapshot/heat/{heat}?projection=result`) and + * store it on {@link heatResult} for the Results screen. The live read stream only + * carries `LiveRaceState`; the scored `HeatResult` is a separate heat-scope read. A + * non-2xx or malformed body leaves `heatResult` unchanged. + */ + async fetchHeatResult(heat: HeatId): Promise { + const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl; + const headers: Record = {}; + if (this.#token) headers.Authorization = `Bearer ${this.#token}`; + try { + const resp = await globalThis.fetch( + `${base}/snapshot/heat/${encodeURIComponent(heat)}?projection=result`, + { headers } + ); + if (!resp.ok) return undefined; + const snap: unknown = await resp.json(); + if ( + snap && + typeof snap === 'object' && + 'body' in snap && + snap.body && + typeof snap.body === 'object' && + 'HeatResult' in snap.body + ) { + this.heatResult = (snap.body as { HeatResult: HeatResult }).HeatResult; + return this.heatResult; + } + } catch { + /* leave heatResult unchanged */ + } + return undefined; + } } diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 1f492c8..c8bdace 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -23,6 +23,7 @@ import type { Session } from '../lib/session.svelte.js'; import ConfirmButton from '../lib/ConfirmButton.svelte'; import ErrorBanner from '../lib/ErrorBanner.svelte'; + import NewHeat from './NewHeat.svelte'; let { session, names = {} }: { session: Session; names?: Record } = $props(); @@ -58,7 +59,13 @@ async function fire(action: HeatAction) { if (!heat) return; - await session.send(commandForAction(action, heat)); + const ack = await session.send(commandForAction(action, heat)); + // Scoring locks in the heat result; pull it so the Results screen has it to show. The + // live stream only carries `LiveRaceState`, so the scored `HeatResult` is a separate + // heat-scope fetch (`?projection=result`). + if (ack.ok && action === 'Score') { + await session.fetchHeatResult(heat); + } } @@ -88,6 +95,8 @@ session.clearCommandError()} /> {/if} + +
    {#each ACTION_ORDER as action (action)} {@const legal = isActionLegal(phase, action)} diff --git a/frontend/apps/rd-console/src/screens/NewHeat.svelte b/frontend/apps/rd-console/src/screens/NewHeat.svelte new file mode 100644 index 0000000..83d38de --- /dev/null +++ b/frontend/apps/rd-console/src/screens/NewHeat.svelte @@ -0,0 +1,179 @@ + + +
    +

    New heat

    +

    + Type the heat id and the pilots flying it. Each name is registered on + sim and becomes the heat's lineup. +

    + + + +
    + Pilots + {#each names as pilot, i (i)} +
    + + +
    + {/each} + +
    + + {#if problems.length > 0} +
      + {#each problems as p (p)}
    • {p}
    • {/each} +
    + {/if} + + +
    + + diff --git a/frontend/apps/rd-console/tests/NewHeat.test.ts b/frontend/apps/rd-console/tests/NewHeat.test.ts new file mode 100644 index 0000000..39c250d --- /dev/null +++ b/frontend/apps/rd-console/tests/NewHeat.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import NewHeat from '../src/screens/NewHeat.svelte'; +import { makeTestSession } from './support.js'; +import { failAck } from './fixtures.js'; + +describe('NewHeat', () => { + it('emits a Register per pilot then a ScheduleHeat from the typed field', async () => { + const { session, sendSpy } = makeTestSession(); + render(NewHeat, { session }); + + // Heat id seeds to 'q-1'; fill the two default pilot rows. + const pilots = screen.getAllByLabelText(/Pilot \d name/) as HTMLInputElement[]; + await fireEvent.input(pilots[0], { target: { value: 'Ace' } }); + await fireEvent.input(pilots[1], { target: { value: 'Bee' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Schedule heat' })); + + // The form sends the batch across awaits; wait for all three to land. + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(3)); + expect(sendSpy.mock.calls.map((c) => c[0])).toEqual([ + { Register: { adapter: 'sim', competitor: 'Ace', pilot: 'Ace' } }, + { Register: { adapter: 'sim', competitor: 'Bee', pilot: 'Bee' } }, + { ScheduleHeat: { heat: 'q-1', lineup: ['Ace', 'Bee'] } } + ]); + }); + + it('disables scheduling until at least two pilots are named', () => { + const { session } = makeTestSession(); + render(NewHeat, { session }); + expect( + (screen.getByRole('button', { name: 'Schedule heat' }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + it('stops the batch on a failed ack (no ScheduleHeat after a failed Register)', async () => { + const { session, sendSpy } = makeTestSession({ ack: failAck }); + render(NewHeat, { session }); + const pilots = screen.getAllByLabelText(/Pilot \d name/) as HTMLInputElement[]; + await fireEvent.input(pilots[0], { target: { value: 'Ace' } }); + await fireEvent.input(pilots[1], { target: { value: 'Bee' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Schedule heat' })); + + // Only the first Register was attempted; the batch aborted before ScheduleHeat. Settle + // briefly to prove no further command is sent after the failed ack. + await waitFor(() => expect(sendSpy).toHaveBeenCalled()); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0][0]).toEqual({ + Register: { adapter: 'sim', competitor: 'Ace', pilot: 'Ace' } + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/heat.test.ts b/frontend/apps/rd-console/tests/heat.test.ts new file mode 100644 index 0000000..0177f09 --- /dev/null +++ b/frontend/apps/rd-console/tests/heat.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + defineHeatCommands, + isHeatValid, + lineupOf, + validateHeat, + type HeatDefinition +} from '../src/lib/heat.js'; + +const def = (heat: string, ...names: string[]): HeatDefinition => ({ + heat, + pilots: names.map((name) => ({ name })) +}); + +describe('heat definition', () => { + it('flags a missing id, too few pilots, and duplicate names', () => { + expect(validateHeat(def('', 'Ace', 'Bee'))).toContain('Heat needs an id.'); + expect(validateHeat(def('q-1', 'Ace'))).toContain('Add at least two pilots.'); + expect(validateHeat(def('q-1', 'Ace', 'Ace'))).toContain('Duplicate pilot name "Ace".'); + }); + + it('accepts a two-pilot named heat', () => { + const d = def('q-1', 'Ace', 'Bee'); + expect(validateHeat(d)).toEqual([]); + expect(isHeatValid(d)).toBe(true); + }); + + it('cleans blank/whitespace names out of the lineup, preserving order', () => { + const d = def('q-1', ' Ace ', '', 'Bee'); + expect(lineupOf(d)).toEqual(['Ace', 'Bee']); + }); + + it('builds one Register per pilot then a ScheduleHeat with the lineup', () => { + const commands = defineHeatCommands(def('q-1', 'Ace', 'Bee')); + expect(commands).toEqual([ + { Register: { adapter: 'sim', competitor: 'Ace', pilot: 'Ace' } }, + { Register: { adapter: 'sim', competitor: 'Bee', pilot: 'Bee' } }, + { ScheduleHeat: { heat: 'q-1', lineup: ['Ace', 'Bee'] } } + ]); + }); +}); From 2eca85c8beac78a40bd3aed3287da50cd9e55483 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 13:57:44 +0000 Subject: [PATCH 020/362] Add console race e2e harness + deterministic RD token (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up a real-browser verification of the full sim-race path through the RD console, and the deterministic login token it needs. Director (crates/app): honour a `GRIDFPV_RD_TOKEN` env — when set to a non-blank value the Director registers it as the RD control token verbatim (via a new additive `TokenStore::register_rd_token`) instead of minting a random one, so an automated client (this e2e, the Tauri app under test) can log in with a known value. Unset → unchanged random-token behaviour. Playwright e2e (frontend/e2e + playwright.config.ts): a `webServer` builds the console and launches the Director with `GRIDFPV_RD_TOKEN=test-rd-token GRIDFPV_SIM_LAPS=4 GRIDFPV_SIM_LAP_MS=250 GRIDFPV_ASSETS=` on a fixed loopback port, serving the SPA at its own origin. One chromium (headless) spec drives the real UI: log in, define 3 named pilots + schedule the heat, Stage → Arm → Start, poll the HeatSheet lap counts climbing in the DOM, Finish → Score, then assert the Results leaderboard lists the pilots with their lap counts. Run with `cd frontend && npm run e2e` (browser via `npm run e2e:install`). `cargo xtask ci` and the root frontend build/check/lint/test stay green. FINDING (verification result = NOT green): the race cannot complete in the browser. The console's read stream is stuck at `snapshotting` — it never reaches `live`. Root cause is a real client/server addressing mismatch, NOT the already-fixed cursor/reactivity bugs: `@gridfpv/protocol-client` requests `GET /snapshot?scope=` (query/JSON scope), but the Director's server only exposes path-scoped routes (`GET /snapshot/event/{event}`, `/heat/...`, etc.). The `?scope=` request therefore falls through to the SPA fallback and returns `index.html` (HTTP 200) instead of a JSON snapshot, so the client never advances past `snapshotting`. Fixing it would require editing `client.ts` and/or the server crate, both out of scope here, so the harness is committed ready and the finding reported. Part of #13 (v0.4 Director wiring). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .gitignore | 6 ++ crates/app/src/main.rs | 14 +++-- crates/server/src/auth.rs | 18 ++++++ frontend/.prettierignore | 4 ++ frontend/e2e/race.spec.ts | 115 ++++++++++++++++++++++++++++++++++ frontend/package-lock.json | 64 +++++++++++++++++++ frontend/package.json | 5 +- frontend/playwright.config.ts | 70 +++++++++++++++++++++ 8 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e/race.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.gitignore b/.gitignore index 7462c25..1c54439 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ 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/ diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index ac52dd0..27a1d6b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -49,10 +49,16 @@ async fn serve() -> Result<(), Box> { None => AppState::new(SqliteLog::open_in_memory()?), }; - // Mint the RD's control token up front and print it with the URL so the RD console (or - // the Tauri app) can log straight in. The store is in-memory, so this is the token for - // *this* run of the process. - let rd_token = state.tokens().issue_rd_token(); + // Mint (or pin) the RD's control token up front and print it with the URL so the RD + // console (or the Tauri app) can log straight in. The store is in-memory, so this is the + // token for *this* run of the process. If `GRIDFPV_RD_TOKEN` is set to a non-blank value + // it is registered as the RD token verbatim — a *known* credential so an automated client + // (the Playwright e2e, the Tauri app under test) can log in deterministically; otherwise a + // fresh random token is minted. + let rd_token = match std::env::var("GRIDFPV_RD_TOKEN") { + Ok(value) if state.tokens().register_rd_token(&value) => value, + _ => state.tokens().issue_rd_token(), + }; // Resolve the built-in lap source (default `sim`) and spawn the control→source bridge // alongside the server: it shares this same `AppState`/log, watches for heats driven to diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 10529f3..71eb6a1 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -125,6 +125,24 @@ impl TokenStore { 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(); diff --git a/frontend/.prettierignore b/frontend/.prettierignore index a102e74..daac325 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -3,3 +3,7 @@ build node_modules .svelte-kit packages/types/vendor + +# Playwright e2e run artifacts (generated) +test-results +playwright-report diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts new file mode 100644 index 0000000..94fb67e --- /dev/null +++ b/frontend/e2e/race.spec.ts @@ -0,0 +1,115 @@ +/** + * Full RD console click-through against a real Director (#13, v0.4 Director wiring). + * + * This is the deliverable proof: a person opens the RD console, logs in, defines a heat + * with named pilots, runs it, **watches the live laps climb in the rendered DOM**, finishes + * + scores, and reads results with the pilots and their lap counts — every step a real + * click/input in headless chromium, every command on the real control path, every lap from + * the real built-in sim source. Nothing is mocked. + * + * The lap-counts-climbing assertion is the load-bearing one: it polls the per-pilot lap + * numbers rendered in the HeatSheet and asserts they increase over a couple of seconds — + * proving the live read stream (protocol-client) and the reactive `Session` push updates + * all the way through the real render path. + * + * The Director (built console SPA + known RD token + fast sim) is stood up by the config's + * `webServer`; `baseURL` points at it. + */ +import { expect, test } from '@playwright/test'; +import { RD_TOKEN } from '../playwright.config.js'; + +const PILOTS = ['Ace', 'Bee', 'Cee']; +const HEAT_ID = 'q-1'; + +test('RD drives a full basic sim race through the console UI', async ({ page, baseURL }) => { + const base = baseURL!; + + // ── Open the console ───────────────────────────────────────────────────────────────── + await page.goto('/'); + + // ── Log in with the Director address + the known RD token ──────────────────────────── + await page.getByLabel('Director address').fill(base); + await page.getByLabel('Control token').fill(RD_TOKEN); + await page.getByRole('button', { name: 'Sign in' }).click(); + + // The shell is up: the Live control screen is the default landing screen. + await expect(page.getByRole('button', { name: /Live control/ })).toBeVisible(); + // The live read stream connects against the Director and settles on `live` (it passes + // through connecting → snapshotting on the way). + await expect(page.locator('.conn-label')).toHaveText('live', { timeout: 15_000 }); + + // ── Define a heat with named pilots ────────────────────────────────────────────────── + const newHeat = page.getByRole('region', { name: 'New heat' }); + await newHeat.getByLabel('Heat id').fill(HEAT_ID); + // Two default pilot rows + one added → three named pilots. + await newHeat.getByRole('button', { name: 'Add pilot' }).click(); + for (let i = 0; i < PILOTS.length; i++) { + await newHeat.getByLabel(`Pilot ${i + 1} name`).fill(PILOTS[i]); + } + + // ── Schedule it ────────────────────────────────────────────────────────────────────── + await newHeat.getByRole('button', { name: 'Schedule heat' }).click(); + + // The heat lands on the timer: current heat + the lineup show, phase Scheduled. + await expect(page.locator('.heat-id .value')).toHaveText(HEAT_ID); + const heatSheet = page.getByRole('region', { name: 'Heat sheet' }); + for (const pilot of PILOTS) { + await expect(heatSheet.getByText(pilot, { exact: true })).toBeVisible(); + } + await expect(page.locator('.phase').first()).toHaveText('Scheduled'); + + // ── Run the heat loop: Stage → Arm → Start ─────────────────────────────────────────── + await page.getByRole('button', { name: 'Stage', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Staged'); + + await page.getByRole('button', { name: 'Arm', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Armed'); + + await page.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Running'); + + // ── Watch the live laps climb in the DOM: poll the rendered per-pilot lap counts ────── + // THIS is the proof the live-stream + reactive-Session path renders updates: the HeatSheet + // `.laps` cells are filled by the live read stream, and their sum must increase over time. + const lapCells = heatSheet.locator('.laps'); + const totalLaps = async () => { + const texts = await lapCells.allTextContents(); + return texts.reduce((sum, t) => sum + (parseInt(t.match(/(\d+)/)?.[1] ?? '0', 10) || 0), 0); + }; + // First, some laps appear in the render. + await expect + .poll(totalLaps, { timeout: 30_000, message: 'live laps should appear in the DOM' }) + .toBeGreaterThan(0); + const early = await totalLaps(); + // Then the count climbs as the sim keeps emitting — the live render path is live. + await expect + .poll(totalLaps, { timeout: 30_000, message: 'live lap counts should climb in the DOM' }) + .toBeGreaterThan(early); + + // ── Finish the window ──────────────────────────────────────────────────────────────── + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Finished'); + + // ── Score the heat ─────────────────────────────────────────────────────────────────── + await page.getByRole('button', { name: 'Score', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Scored'); + + // ── Read the results: the Results screen shows the pilots, lap counts, and an order ─── + await page.getByRole('button', { name: /Results/ }).click(); + const results = page.getByRole('region', { name: 'Results' }); + const leaderboard = results.getByRole('table', { name: 'Heat leaderboard' }); + await expect(leaderboard).toBeVisible(); + + // Every pilot appears in the result with a decided finishing order (positions 1..n). + const rows = leaderboard.locator('tbody tr'); + await expect(rows).toHaveCount(PILOTS.length); + for (const pilot of PILOTS) { + await expect(leaderboard.getByRole('cell', { name: pilot, exact: true })).toBeVisible(); + } + // The leader (position 1) banked at least one lap — a real, scored result. + const leaderLaps = await rows.first().locator('.laps').textContent(); + expect(parseInt(leaderLaps ?? '0', 10)).toBeGreaterThan(0); + // Positions are decided and start at 1. + const firstPos = await rows.first().locator('.pos .badge').textContent(); + expect(firstPos?.trim()).toBe('1'); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index debb119..e5a9c2f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ ], "devDependencies": { "@eslint/js": "^9.17.0", + "@playwright/test": "^1.49.1", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.46.1", @@ -1087,6 +1088,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -3733,6 +3750,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index d2f8f98..7778c33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,13 @@ "test": "npm run test --workspaces --if-present", "lint": "eslint . && prettier --check .", "format": "prettier --write .", - "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console" + "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console", + "e2e": "playwright test", + "e2e:install": "playwright install chromium" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@playwright/test": "^1.49.1", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.46.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..d88bbc3 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,70 @@ +/** + * Playwright config for the RD console e2e (#13, v0.4 Director wiring). + * + * One spec drives the **full RD click-through in real headless chromium** — log in, define + * a heat with named pilots, run it, watch the live laps climb in the rendered DOM, finish, + * score, read results — against a **real** Director (`gridfpv` binary, built-in sim source). + * + * `webServer` stands the whole stack up for the run, in order, on a fixed port: + * 1. `npm run build` the console (and its workspace deps) so `dist/` matches the source; + * 2. launch the Director with a *known* RD token (`GRIDFPV_RD_TOKEN`) and a fast sim + * (4 laps @ 250ms) serving that `dist/` as its SPA (`GRIDFPV_ASSETS`). + * The Director serves the SPA at its own origin, so the spec navigates straight at it — + * one origin, the real control path, the real sim laps. Playwright waits on `/health`. + * + * Headless chromium only: the console is the dense desktop surface; this proves the + * click-through and the live render path, not cross-browser rendering. + */ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const frontendRoot = here; +const repoRoot = resolve(frontendRoot, '..'); +const dist = resolve(frontendRoot, 'apps', 'rd-console', 'dist'); +const director = resolve(repoRoot, 'target', 'debug', 'gridfpv'); + +// A fixed, loopback-only port + the known RD token the spec logs in with. +const PORT = 8123; +const BASE_URL = `http://127.0.0.1:${PORT}`; +export const RD_TOKEN = 'test-rd-token'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + // A real sim race takes a few seconds across its phases; give generous headroom. + timeout: 90_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? 'line' : 'list', + use: { + baseURL: BASE_URL, + headless: true, + trace: 'retain-on-failure' + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + // Build the console — `npm run build` builds every workspace, so the rd-console SPA + // gets its freshly built `@gridfpv/*` deps — then exec the real Director on $PORT, + // serving that SPA with a known token and a fast sim. `exec` replaces the shell with the + // Director so Playwright's process tree can stop it cleanly. + command: `npm run build && exec ${JSON.stringify(director)}`, + cwd: frontendRoot, + url: `${BASE_URL}/health`, + timeout: 180_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + env: { + GRIDFPV_ADDR: `127.0.0.1:${PORT}`, + GRIDFPV_RD_TOKEN: RD_TOKEN, + GRIDFPV_SIM_LAPS: '4', + GRIDFPV_SIM_LAP_MS: '250', + GRIDFPV_ASSETS: dist + } + } +}); From 19a7b33ac7ec0d60a7cebd35640e505e1d3914be Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 14:01:33 +0000 Subject: [PATCH 021/362] fix(protocol-client): address snapshots by path, matching the server routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client requested GET /snapshot?scope=, but the server only serves path-scoped snapshot routes (/snapshot/event/{id}, /snapshot/heat/{id}, etc. — protocol.html §4). The query form matched no route and fell through to the SPA fallback, returning index.html instead of JSON, so the client never reached 'live' (the race never started in the browser). Map each Scope variant to its server path. Third integration-seam bug between the independently-built snapshot endpoint (#42) and protocol client (#49); unit tests served any URL so missed it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../protocol-client/src/client.test.ts | 4 ++-- .../packages/protocol-client/src/client.ts | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index 3146161..896c1be 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -146,9 +146,9 @@ describe('ProtocolClient', () => { }); await flush(); - // Snapshot was fetched and applied. + // Snapshot was fetched (path-scoped, matching the server's routes) and applied. expect(calls).toHaveLength(1); - expect(calls[0]).toContain('/snapshot?scope='); + expect(calls[0]).toContain('/snapshot/heat/heat-1'); expect(client.getState().cursor).toBe(10n); expect(phaseOf(client.getState().body)).toBe('Staged'); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 2d6a7de..bd876c0 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -178,6 +178,25 @@ function toWebSocketBase(baseUrl: string): string { const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s); +/** + * Build the snapshot path for a scope. The server addresses snapshots by PATH + * (`/snapshot/event/{id}`, `/snapshot/heat/{id}`, `/snapshot/class/{event}/{class}`, + * `/snapshot/pilot/{event}/{pilot}` — protocol.html §4 endpoint surface), NOT a + * `?scope=` query, so the client maps the scope to that path. + */ +function snapshotPath(scope: Scope): string { + if ('Event' in scope) return `/snapshot/event/${encodeURIComponent(scope.Event.event)}`; + if ('Heat' in scope) return `/snapshot/heat/${encodeURIComponent(scope.Heat.heat)}`; + if ('Class' in scope) { + return `/snapshot/class/${encodeURIComponent(scope.Class.event)}/${encodeURIComponent( + scope.Class.class + )}`; + } + return `/snapshot/pilot/${encodeURIComponent(scope.Pilot.event)}/${encodeURIComponent( + scope.Pilot.pilot + )}`; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * @@ -198,7 +217,6 @@ export function connect(options: ConnectOptions): ProtocolClient { const wsBase = trimSlash(toWebSocketBase(options.baseUrl)); const scope = options.scope; const token = options.token; - const scopeParam = encodeURIComponent(JSON.stringify(scope)); // ── Mutable connection state ─────────────────────────────────────────────── let body: ProjectionBody | undefined; @@ -246,7 +264,7 @@ export function connect(options: ConnectOptions): ProtocolClient { if (token) headers.Authorization = `Bearer ${token}`; let resp: Response; try { - resp = await fetchImpl(`${baseUrl}/snapshot?scope=${scopeParam}`, { headers }); + resp = await fetchImpl(`${baseUrl}${snapshotPath(scope)}`, { headers }); } catch (e) { if (gen !== generation || closed) return false; fail({ code: 'Internal', message: `snapshot fetch failed: ${String(e)}` }); From 231084566c0505a6ec9a81ec76a9d18429f33ad8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 14:16:12 +0000 Subject: [PATCH 022/362] fix(protocol-client): unwrap StreamMessage frames from the WS stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server sends each stream frame as a StreamMessage (protocol.html §3), externally tagged: { "Change": ChangeEnvelope } | { "ReSnapshotRequired": ProtocolError }. The client checked for a raw ChangeEnvelope ({sequence,projection,change}) at the top level, so every wrapped frame fell through to "unknown frame → ignore" — the live view received the data but never applied it (the race never advanced in the browser). Unwrap StreamMessage: apply Change, re-snapshot on ReSnapshotRequired, fail/re-snapshot on a bare ProtocolError. Tests now emit the wrapped form the server actually sends (the raw-envelope mocks masked this). Fourth integration-seam bug: A4 added the StreamMessage wrapper after the client (#49) was built; the client was never updated. Verified: real client vs real Director now reaches current_heat=q-1 over the stream. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../protocol-client/src/client.test.ts | 40 +++++++++------- .../packages/protocol-client/src/client.ts | 48 +++++++++++-------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index 896c1be..d70cc6c 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -121,6 +121,14 @@ const envelope = (sequence: bigint, phase: HeatPhase): ChangeEnvelope => ({ change: { FreshValue: liveState(phase) } }); +/** + * Wrap an envelope as the `StreamMessage` the server actually sends on the wire: + * `{ Change: ChangeEnvelope }` (externally tagged). The client must unwrap it — a + * raw, unwrapped envelope was the shape these mocks used before, which masked the + * client ignoring every real (wrapped) frame. + */ +const change = (sequence: bigint, phase: HeatPhase) => ({ Change: envelope(sequence, phase) }); + // Let queued microtasks (the async snapshot fetch) settle. const flush = async (): Promise => { await Promise.resolve(); @@ -176,9 +184,9 @@ describe('ProtocolClient', () => { await flush(); sockets[0].open(); - sockets[0].emit(envelope(1n, 'Staged')); - sockets[0].emit(envelope(2n, 'Armed')); - sockets[0].emit(envelope(3n, 'Running')); + sockets[0].emit(change(1n, 'Staged')); + sockets[0].emit(change(2n, 'Armed')); + sockets[0].emit(change(3n, 'Running')); // Every envelope applied → body converged. The resume cursor stays the snapshot // offset (the stream sequence is not a log offset). @@ -195,12 +203,12 @@ describe('ProtocolClient', () => { await flush(); sockets[0].open(); - sockets[0].emit(envelope(1n, 'Staged')); - sockets[0].emit(envelope(2n, 'Armed')); + sockets[0].emit(change(1n, 'Staged')); + sockets[0].emit(change(2n, 'Armed')); // Re-deliver 1 and 2 (at-least-once): they're at/below the last applied sequence // (2), so they're no-ops and must not regress the state back to 'Scheduled'. - sockets[0].emit(envelope(1n, 'Scheduled')); - sockets[0].emit(envelope(2n, 'Scheduled')); + sockets[0].emit(change(1n, 'Scheduled')); + sockets[0].emit(change(2n, 'Scheduled')); expect(phaseOf(client.getState().body)).toBe('Armed'); @@ -217,9 +225,9 @@ describe('ProtocolClient', () => { await flush(); sockets[0].open(); - sockets[0].emit(envelope(1n, 'Staged')); + sockets[0].emit(change(1n, 'Staged')); // Gap: jump to 4 (missed 2 and 3). The client must re-snapshot. - sockets[0].emit(envelope(4n, 'Armed')); + sockets[0].emit(change(4n, 'Armed')); await flush(); // A second snapshot fetch happened and the old socket was torn down. @@ -237,7 +245,7 @@ describe('ProtocolClient', () => { // The fresh subscription restarts the per-stream sequence, so its first envelope // is accepted and the body converges. The resume cursor stays the re-snapshot // offset (5). - sockets[1].emit(envelope(6n, 'Finished')); + sockets[1].emit(change(6n, 'Finished')); expect(phaseOf(client.getState().body)).toBe('Finished'); expect(client.getState().cursor).toBe(5n); @@ -255,7 +263,7 @@ describe('ProtocolClient', () => { sockets[0].open(); const staleErr: ProtocolError = { code: 'StaleCursor', message: 'cursor too old to replay' }; - sockets[0].emit({ error: staleErr }); + sockets[0].emit({ ReSnapshotRequired: staleErr }); await flush(); expect(calls).toHaveLength(2); @@ -280,8 +288,8 @@ describe('ProtocolClient', () => { }); await flush(); sockets[0].open(); - sockets[0].emit(envelope(1n, 'Staged')); - sockets[0].emit(envelope(2n, 'Armed')); + sockets[0].emit(change(1n, 'Staged')); + sockets[0].emit(change(2n, 'Armed')); // Socket drops. sockets[0].drop(); @@ -304,7 +312,7 @@ describe('ProtocolClient', () => { expect(client.getState().status).toBe('live'); // The resumed subscription restarts the sequence; its first envelope converges. - sockets[1].emit(envelope(1n, 'Running')); + sockets[1].emit(change(1n, 'Running')); expect(phaseOf(client.getState().body)).toBe('Running'); client.close(); @@ -319,14 +327,14 @@ describe('ProtocolClient', () => { const unsub = client.onState((s) => seen.push(phaseOf(s.body))); await flush(); sockets[0].open(); - sockets[0].emit(envelope(1n, 'Staged')); + sockets[0].emit(change(1n, 'Staged')); expect(seen).toContain('Scheduled'); expect(seen).toContain('Staged'); unsub(); const before = seen.length; - sockets[0].emit(envelope(2n, 'Armed')); + sockets[0].emit(change(2n, 'Armed')); expect(seen.length).toBe(before); // unsubscribed: no more notifications client.close(); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index bd876c0..5caab31 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -115,10 +115,9 @@ export interface ProtocolClient { close(): void; } -/** Error frame the server may send on the stream (protocol.html §9.8). */ -interface ErrorFrame { - error: ProtocolError; -} +// The stream frames are `StreamMessage` (protocol.html §3, bindings/StreamMessage.ts), +// externally tagged: `{ "Change": ChangeEnvelope }` | `{ "ReSnapshotRequired": ProtocolError }`. +// A bare `ProtocolError` may also arrive (e.g. a VersionMismatch just before close). // ── Cursor (bigint) wire handling ────────────────────────────────────────────── // @@ -166,8 +165,14 @@ const isProtocolError = (v: unknown): v is ProtocolError => const isChangeEnvelope = (v: unknown): v is ChangeEnvelope => typeof v === 'object' && v !== null && 'sequence' in v && 'projection' in v && 'change' in v; -const isErrorFrame = (v: unknown): v is ErrorFrame => - typeof v === 'object' && v !== null && 'error' in v && isProtocolError((v as ErrorFrame).error); +const isStreamChange = (v: unknown): v is { Change: ChangeEnvelope } => + typeof v === 'object' && + v !== null && + 'Change' in v && + isChangeEnvelope((v as { Change: unknown }).Change); + +const isReSnapshotRequired = (v: unknown): v is { ReSnapshotRequired: ProtocolError } => + typeof v === 'object' && v !== null && 'ReSnapshotRequired' in v; /** Map an http(s) base URL to its ws(s) equivalent. */ function toWebSocketBase(baseUrl: string): string { @@ -371,29 +376,34 @@ export function connect(options: ConnectOptions): ProtocolClient { return; } - if (isErrorFrame(parsed)) { - // A stale cursor means resume is impossible → re-snapshot from scratch (§3). - if (parsed.error.code === 'StaleCursor') { + // `StreamMessage::Change(envelope)` — the common case: unwrap + apply. + if (isStreamChange(parsed)) { + const result = applyEnvelope(normalizeEnvelope(parsed.Change)); + if (result === 'gap') { + // Missed envelopes the stream can't replay → re-snapshot and re-subscribe. await resnapshot(gen); - } else { - fail(parsed.error); + return; } + if (result === 'applied') emit(); return; } - if (!isChangeEnvelope(parsed)) { - // Unknown frame (e.g. a ServerHello in a richer handshake) — ignore it so an - // additive protocol change doesn't break an older client (§7). + // `StreamMessage::ReSnapshotRequired(error)` — the cursor is unreplayable → + // re-snapshot from scratch (always correct, §3). + if (isReSnapshotRequired(parsed)) { + await resnapshot(gen); return; } - const result = applyEnvelope(normalizeEnvelope(parsed)); - if (result === 'gap') { - // Missed envelopes the stream can't replay → re-snapshot and re-subscribe. - await resnapshot(gen); + // A bare ProtocolError (e.g. a VersionMismatch sent just before the socket closes). + if (isProtocolError(parsed)) { + if (parsed.code === 'StaleCursor') await resnapshot(gen); + else fail(parsed); return; } - if (result === 'applied') emit(); + + // Unknown frame (e.g. a future additive message) — ignore it so an additive + // protocol change doesn't break an older client (§7). } // Re-snapshot in place (gap or stale cursor), then re-subscribe from the fresh From 33274e1748104aa84fbe5138b1dc9c5b0eed018a Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 14:37:11 +0000 Subject: [PATCH 023/362] docs(race-engine): flag signal-level marshaling + adapter capability for further discussion Note the lap-level (timer-agnostic, have it) vs signal-level (RSSI re-thresholding, timer-specific) split, the decision-vs-commit separation, the adapter-capability interface, and the ingest-vs-delegate open question. Maintainer to use the interface hands-on before deciding what the marshaling data model needs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/race-engine.html | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/race-engine.html b/docs/race-engine.html index 7bfcc16..faac08e 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -162,6 +162,25 @@

    4. Scoring & lap derivation

    before the window closes; an in-progress lap at the cutoff does not count. +
    +

    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

    When a heat is scored, its result is handed to the format generator, which decides From e15871e4950cf2876214bf79d899b5c1f27c15d1 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 14:41:35 +0000 Subject: [PATCH 024/362] fix(components): coerce i64 micros to Number before arithmetic (the render freeze) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatMicros divided by 1000n, but ts-rs types i64 as bigint while serde sends it as a JSON number — so at runtime `micros` is a number and `number / 1000n` throws "Cannot mix BigInt and other types". Thrown mid-render (HeatSheet/Leaderboard last-lap cells), it aborted the reactive update, so live laps never painted even though the data was flowing. Coerce with Number() first. Confirmed end to end: the browser e2e now drives a full sim race (login → define pilots → schedule → stage/arm/start → live laps climb in the DOM → finish → score → results), green. Fifth integration-seam bug, same root theme as the others: wire-vs-type drift (i64 bigint-vs-number) — motivates the strict contract suite. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/packages/components/src/format.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts index e2fc2b6..00b2126 100644 --- a/frontend/packages/components/src/format.ts +++ b/frontend/packages/components/src/format.ts @@ -22,9 +22,14 @@ export function formatClock(ms: number): string { * Format a lap/split duration given in **microseconds** (the wire unit) as * `S.mmm` seconds, or minutes:seconds when ≥ 60s. `null`/`undefined` → `'—'`. */ -export function formatMicros(micros: bigint | null | undefined): string { +export function formatMicros(micros: bigint | number | null | undefined): string { if (micros === null || micros === undefined) return '—'; - const totalMs = Number(micros / 1000n); + // ts-rs types i64 micros as `bigint`, but serde sends i64 as a JSON *number*, so the + // runtime value is usually a `number`. Coerce to Number BEFORE any arithmetic — mixing + // a bigint and a number (e.g. `250000 / 1000n`) throws "Cannot mix BigInt and other types" + // and, thrown mid-render, aborts the update. (The wire-vs-type i64/bigint mismatch is a + // class to address systematically in the contract review.) + const totalMs = Math.floor(Number(micros) / 1000); if (totalMs >= 60000) return formatClock(totalMs); const seconds = Math.floor(totalMs / 1000); const millis = totalMs % 1000; From b6e45bf796417f89882e436c7757dcec488c4da4 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 14:58:10 +0000 Subject: [PATCH 025/362] Render wire integer types as TS `number`, not `bigint` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every transferred integer in the GridFPV wire contract is bounded far below `Number.MAX_SAFE_INTEGER` (~9e15) in our domain, and serde already serialises them as JSON numbers. Rendering them as TS `bigint` only produced wire-vs-type mismatches (a `number` arriving where a `bigint` was declared). This keeps the Rust types as `i64`/`u64` (the engine still does arithmetic on them) and changes only the ts-rs TS rendering plus the now-pointless bigint plumbing in the frontend. ts-rs rendering (→ `number`): - `SourceTime`, `LogRef`, `Cursor`: transparent newtypes, `#[ts(as = "f64")]`. - `Pass.sequence`, `Penalty::TimeAdded.micros`, `Lap.duration_micros`, `WinCondition::Timed.window_micros`, `PilotProgress.last_lap_micros`: `#[ts(type = "number")]`. - `Metric::BestLapMicros` / `BestConsecutiveMicros`: `#[ts(type = "number | null")]`. - Updated `Cursor` doc comment (no longer claims u64 exceeds JS safe range). - Regenerated `bindings/`; zero `bigint` remain. Frontend: - protocol-client: stripped `toCursor`/`stringifyWire`/`normalize*` (cursors are plain numbers from JSON now); `streamSeq`/`cursor` are `number`; `0n`→`0`, `+ 1n`→`+ 1`. Ordering/apply logic unchanged. Updated tests to plain numbers. - components `formatMicros`: param is now `number | null | undefined`; kept the `Number()` coercion as belt-and-suspenders; updated doc comment. - rd-console consumers (setup/marshaling/control/results + screens + fixtures/ tests) updated to build/assert plain `number`s instead of bigint. Part of #13 (v0.4 wire data-type contract). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/Cursor.ts | 7 +- bindings/Lap.ts | 3 +- bindings/LogRef.ts | 2 +- bindings/Metric.ts | 2 +- bindings/Pass.ts | 2 +- bindings/Penalty.ts | 2 +- bindings/PilotProgress.ts | 4 +- bindings/SourceTime.ts | 2 +- bindings/WinCondition.ts | 3 +- crates/engine/src/scoring.rs | 6 +- crates/events/src/lib.rs | 23 ++++-- crates/projection/src/lib.rs | 2 + crates/server/src/live_state.rs | 4 +- crates/server/src/stream.rs | 7 +- frontend/apps/rd-console/src/lib/control.ts | 13 ++-- .../apps/rd-console/src/lib/marshaling.ts | 7 +- frontend/apps/rd-console/src/lib/results.ts | 3 +- frontend/apps/rd-console/src/lib/setup.ts | 2 +- .../rd-console/src/screens/Marshaling.svelte | 4 +- .../rd-console/src/screens/SetupWizard.svelte | 5 +- .../rd-console/tests/MarshalingScreen.test.ts | 4 +- .../apps/rd-console/tests/control.test.ts | 4 +- frontend/apps/rd-console/tests/fixtures.ts | 26 +++---- .../apps/rd-console/tests/marshaling.test.ts | 12 +-- .../apps/rd-console/tests/results.test.ts | 2 +- .../rd-console/tests/session.svelte.test.ts | 2 +- frontend/apps/rd-console/tests/setup.test.ts | 2 +- frontend/apps/rd-console/tests/support.ts | 2 +- frontend/packages/components/src/format.ts | 13 ++-- .../packages/components/tests/fixtures.ts | 10 +-- .../packages/components/tests/format.test.ts | 12 +-- .../protocol-client/src/client.test.ts | 78 +++++++++---------- .../packages/protocol-client/src/client.ts | 53 +++---------- 33 files changed, 156 insertions(+), 167 deletions(-) diff --git a/bindings/Cursor.ts b/bindings/Cursor.ts index b193c80..a066c26 100644 --- a/bindings/Cursor.ts +++ b/bindings/Cursor.ts @@ -11,7 +11,8 @@ * advances it; on reconnect the client presents its last-applied cursor to resume * (§3). * - * Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint` - * (a `u64` exceeds JS's safe-integer range), matching how it serialises. + * 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 = bigint; +export type Cursor = number; diff --git a/bindings/Lap.ts b/bindings/Lap.ts index bb48203..2776c19 100644 --- a/bindings/Lap.ts +++ b/bindings/Lap.ts @@ -11,5 +11,6 @@ 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: bigint, }; +duration_micros: number, }; 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/Metric.ts b/bindings/Metric.ts index 93c2e69..1114c07 100644 --- a/bindings/Metric.ts +++ b/bindings/Metric.ts @@ -5,4 +5,4 @@ import type { SourceTime } from "./SourceTime"; * The condition-specific value a [`Placement`] was ranked on, kept for display and * for tests to assert against exact numbers. */ -export type Metric = { "LastLapAt": SourceTime | null } | { "ReachedAt": SourceTime | null } | { "BestLapMicros": bigint | null } | { "BestConsecutiveMicros": bigint | null }; +export type Metric = { "LastLapAt": SourceTime | null } | { "ReachedAt": SourceTime | null } | { "BestLapMicros": number | null } | { "BestConsecutiveMicros": number | null }; diff --git a/bindings/Pass.ts b/bindings/Pass.ts index 11fc5c4..64d4651 100644 --- a/bindings/Pass.ts +++ b/bindings/Pass.ts @@ -26,7 +26,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. */ diff --git a/bindings/Penalty.ts b/bindings/Penalty.ts index 5848242..7b02c36 100644 --- a/bindings/Penalty.ts +++ b/bindings/Penalty.ts @@ -3,4 +3,4 @@ /** * A marshaling penalty applied to a competitor in a heat. */ -export type Penalty = "Disqualify" | { "TimeAdded": { micros: bigint, } }; +export type Penalty = "Disqualify" | { "TimeAdded": { micros: number, } }; diff --git a/bindings/PilotProgress.ts b/bindings/PilotProgress.ts index 9264193..f0ed447 100644 --- a/bindings/PilotProgress.ts +++ b/bindings/PilotProgress.ts @@ -26,6 +26,6 @@ pilot?: PilotId, laps_completed: number, /** * Duration (µs, source clock) of the most recently completed lap, or `None` before - * the pilot has completed a lap. + * the pilot has completed a lap. Renders as a plain TS `number` (bounded far below 2^53). */ -last_lap_micros?: bigint, }; +last_lap_micros?: number, }; 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/WinCondition.ts b/bindings/WinCondition.ts index 872c2ab..f79dd69 100644 --- a/bindings/WinCondition.ts +++ b/bindings/WinCondition.ts @@ -7,8 +7,9 @@ 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: bigint, } } | { "FirstToLaps": { +window_micros: number, } } | { "FirstToLaps": { /** * The target lap count. */ diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs index cd5cf64..29b9724 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -58,6 +58,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 @@ -114,10 +116,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. diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 8721747..4c1f343 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -71,10 +71,11 @@ pub struct PilotId(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, @@ -144,8 +145,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")] @@ -173,7 +176,10 @@ pub struct HeatId(pub String); /// 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 @@ -212,7 +218,10 @@ pub enum Penalty { /// Disqualified from the heat. Disqualify, /// Time added to the competitor's result, in microseconds. - TimeAdded { micros: i64 }, + TimeAdded { + #[ts(type = "number")] + micros: i64, + }, } /// A canonical event in the append-only log. diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index e338488..02e61df 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -66,6 +66,8 @@ pub struct Lap { 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, } diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 3b04942..7c2ca0e 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -117,9 +117,9 @@ pub struct PilotProgress { /// 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. + /// 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)] + #[ts(optional, type = "number")] pub last_lap_micros: Option, } diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs index 597f58c..598f445 100644 --- a/crates/server/src/stream.rs +++ b/crates/server/src/stream.rs @@ -29,11 +29,12 @@ use crate::snapshot::{ProjectionBody, ProjectionKind}; /// advances it; on reconnect the client presents its last-applied cursor to resume /// (§3). /// -/// Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint` -/// (a `u64` exceeds JS's safe-integer range), matching how it serialises. +/// 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 = "u64")] +#[ts(export, export_to = "bindings/", as = "f64")] pub struct Cursor { /// The monotonic per-stream sequence value. pub seq: u64, diff --git a/frontend/apps/rd-console/src/lib/control.ts b/frontend/apps/rd-console/src/lib/control.ts index e1c5676..325d284 100644 --- a/frontend/apps/rd-console/src/lib/control.ts +++ b/frontend/apps/rd-console/src/lib/control.ts @@ -27,11 +27,10 @@ * `@gridfpv/types`, so only the transport seam below moves — every screen builds a * typed `Command` and calls `sendCommand`, agnostic to how it ships. * - * `Command` carries `bigint` fields (`SourceTime`, `LogRef`, `Penalty.TimeAdded`). - * `JSON.stringify` cannot serialize a `bigint`, so {@link stringifyCommand} renders - * them as JSON numbers — matching serde's u64/i64 default and the read client's - * `stringifyWire`. (A deployment needing full-u64 precision would serialize as - * strings on both sides; this mirrors that decision when #45 makes it.) + * `Command`'s numeric fields (`SourceTime`, `LogRef`, `Penalty.TimeAdded`) are plain + * `number`s — bounded far below 2^53 in our domain and rendered as JSON numbers, which + * is exactly serde's u64/i64 default. {@link stringifyCommand} is a plain + * `JSON.stringify`; the legacy bigint replacer is kept as a defensive no-op. */ import type { Command, CommandAck, ProtocolError } from '@gridfpv/types'; @@ -54,8 +53,8 @@ export interface ControlClient { const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s); /** - * `JSON.stringify` for a `Command`, rendering `bigint` fields as JSON numbers - * (serde's default for u64/i64). See the module note on full-u64 precision. + * `JSON.stringify` for a `Command`. Its numeric fields are plain `number`s, so this is + * a straight serialize; the bigint replacer is a defensive no-op for any stray value. */ export function stringifyCommand(command: Command): string { return JSON.stringify(command, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); diff --git a/frontend/apps/rd-console/src/lib/marshaling.ts b/frontend/apps/rd-console/src/lib/marshaling.ts index 37dd7df..fecf384 100644 --- a/frontend/apps/rd-console/src/lib/marshaling.ts +++ b/frontend/apps/rd-console/src/lib/marshaling.ts @@ -10,7 +10,8 @@ * * `Command`'s five marshaling variants are pure functions of their inputs here, so the * screen builds them declaratively and the tests assert the exact wire shape. Targets - * are `LogRef` (a u64 log offset, `bigint`); times are `SourceTime` (µs, `bigint`). + * are `LogRef` (a u64 log offset, rendered as a TS `number`); times are `SourceTime` + * (µs, rendered as a TS `number`). */ import type { @@ -58,7 +59,7 @@ export function applyPenaltyCommand( /** Build a `TimeAdded` penalty from a whole-second amount (the console's input unit). */ export function timeAddedPenalty(seconds: number): Penalty { - return { TimeAdded: { micros: BigInt(Math.round(seconds * 1_000_000)) } }; + return { TimeAdded: { micros: Math.round(seconds * 1_000_000) } }; } /** The disqualification penalty. */ @@ -66,5 +67,5 @@ export const DISQUALIFY: Penalty = 'Disqualify'; /** Convert the console's whole-second time input to `SourceTime` microseconds. */ export function secondsToSourceTime(seconds: number): SourceTime { - return BigInt(Math.round(seconds * 1_000_000)); + return Math.round(seconds * 1_000_000); } diff --git a/frontend/apps/rd-console/src/lib/results.ts b/frontend/apps/rd-console/src/lib/results.ts index f7efa69..6e58eac 100644 --- a/frontend/apps/rd-console/src/lib/results.ts +++ b/frontend/apps/rd-console/src/lib/results.ts @@ -72,7 +72,8 @@ function roundNameFor(size: number, count: number): string { return `Round of ${size * 2}`; } -/** Serialize a value to pretty JSON, rendering `bigint` as a JSON number. */ +/** Serialize a value to pretty JSON; the bigint replacer is a defensive no-op now + * that wire numerics are plain `number`s. */ export function toExportJson(value: unknown): string { return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? Number(v) : v), 2); } diff --git a/frontend/apps/rd-console/src/lib/setup.ts b/frontend/apps/rd-console/src/lib/setup.ts index 8ce34d2..0b582e9 100644 --- a/frontend/apps/rd-console/src/lib/setup.ts +++ b/frontend/apps/rd-console/src/lib/setup.ts @@ -66,7 +66,7 @@ export function defaultWinCondition(format: RaceFormat): WinCondition { switch (format) { case 'timed-qual': // A 2-minute timed window (µs). - return { Timed: { window_micros: 120_000_000n } }; + return { Timed: { window_micros: 120_000_000 } }; case 'single-elim': // First to 3 laps decides a bracket heat. return { FirstToLaps: { n: 3 } }; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 48f7f06..c13d8fe 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -52,10 +52,10 @@ let target = $state(0); let adjustAt = $state(0); async function doVoidDetection() { - await session.send(voidDetectionCommand(BigInt(Math.trunc(target)))); + await session.send(voidDetectionCommand(Math.trunc(target))); } async function doAdjust() { - await session.send(adjustLapCommand(BigInt(Math.trunc(target)), secondsToSourceTime(adjustAt))); + await session.send(adjustLapCommand(Math.trunc(target), secondsToSourceTime(adjustAt))); } // ── Penalty form ── diff --git a/frontend/apps/rd-console/src/screens/SetupWizard.svelte b/frontend/apps/rd-console/src/screens/SetupWizard.svelte index 12652b1..ba23fb7 100644 --- a/frontend/apps/rd-console/src/screens/SetupWizard.svelte +++ b/frontend/apps/rd-console/src/screens/SetupWizard.svelte @@ -52,7 +52,8 @@ // Win-condition is a discriminated union; expose the numeric knob for the chosen kind. function winConditionNumber(wc: WinCondition): number { - if (typeof wc === 'object' && 'Timed' in wc) return Number(wc.Timed.window_micros / 1_000_000n); + if (typeof wc === 'object' && 'Timed' in wc) + return Math.round(wc.Timed.window_micros / 1_000_000); if (typeof wc === 'object' && 'FirstToLaps' in wc) return wc.FirstToLaps.n; if (typeof wc === 'object' && 'BestConsecutive' in wc) return wc.BestConsecutive.n; return 0; @@ -69,7 +70,7 @@ const wc = c.winCondition; let next: WinCondition = wc; if (typeof wc === 'object' && 'Timed' in wc) - next = { Timed: { window_micros: BigInt(Math.round(value * 1_000_000)) } }; + next = { Timed: { window_micros: Math.round(value * 1_000_000) } }; else if (typeof wc === 'object' && 'FirstToLaps' in wc) next = { FirstToLaps: { n: Math.max(1, Math.round(value)) } }; else if (typeof wc === 'object' && 'BestConsecutive' in wc) diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 613ca22..db03ff6 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -35,7 +35,7 @@ describe('Marshaling', () => { ApplyPenalty: { heat: 'heat-1', competitor: 'BOB', - penalty: { TimeAdded: { micros: 2_000_000n } } + penalty: { TimeAdded: { micros: 2_000_000 } } } }); }); @@ -45,6 +45,6 @@ describe('Marshaling', () => { render(Marshaling, { session }); await fireEvent.click(screen.getByRole('button', { name: 'Void detection' })); - expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 0n } }); + expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 0 } }); }); }); diff --git a/frontend/apps/rd-console/tests/control.test.ts b/frontend/apps/rd-console/tests/control.test.ts index 6d32780..1c28cb1 100644 --- a/frontend/apps/rd-console/tests/control.test.ts +++ b/frontend/apps/rd-console/tests/control.test.ts @@ -71,8 +71,8 @@ describe('createControlClient', () => { expect(ack.error?.code).toBe('Internal'); }); - it('renders bigint command fields as JSON numbers (serde u64 default)', () => { - const cmd: Command = { AdjustLap: { target: 42n, at: 1_500_000n } }; + it('renders numeric command fields as JSON numbers (serde u64 default)', () => { + const cmd: Command = { AdjustLap: { target: 42, at: 1_500_000 } }; expect(JSON.parse(stringifyCommand(cmd))).toEqual({ AdjustLap: { target: 42, at: 1_500_000 } }); diff --git a/frontend/apps/rd-console/tests/fixtures.ts b/frontend/apps/rd-console/tests/fixtures.ts index 97c9ddc..15874bb 100644 --- a/frontend/apps/rd-console/tests/fixtures.ts +++ b/frontend/apps/rd-console/tests/fixtures.ts @@ -16,8 +16,8 @@ export const liveRunning: LiveRaceState = { phase: 'Running', active_pilots: ['ALICE', 'BOB', 'CARMEN'], progress: [ - { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000n }, - { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000n }, + { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000 }, + { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000 }, { competitor: 'CARMEN', laps_completed: 2, last_lap_micros: undefined } ], running_order: ['ALICE', 'BOB', 'CARMEN'], @@ -30,13 +30,13 @@ export const heatResult: HeatResult = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_250_000n } + metric: { BestLapMicros: 41_250_000 } }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 42_100_000n } + metric: { BestLapMicros: 42_100_000 } } ] }; @@ -52,13 +52,13 @@ export const lapList: LapList = { { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, laps: [ - { number: 1, duration_micros: 41_000_000n }, - { number: 2, duration_micros: 40_500_000n } + { number: 1, duration_micros: 41_000_000 }, + { number: 2, duration_micros: 40_500_000 } ] }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, - laps: [{ number: 1, duration_micros: 43_000_000n }] + laps: [{ number: 1, duration_micros: 43_000_000 }] } ] }; @@ -80,13 +80,13 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_000_000n } + metric: { BestLapMicros: 41_000_000 } }, { competitor: { adapter: 'rh-1', competitor: 'DANA' }, position: 2, laps: 3, - metric: { BestLapMicros: 45_000_000n } + metric: { BestLapMicros: 45_000_000 } } ] } @@ -99,13 +99,13 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 1, laps: 3, - metric: { BestLapMicros: 42_000_000n } + metric: { BestLapMicros: 42_000_000 } }, { competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, position: 2, laps: 3, - metric: { BestLapMicros: 46_000_000n } + metric: { BestLapMicros: 46_000_000 } } ] } @@ -118,13 +118,13 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 40_000_000n } + metric: { BestLapMicros: 40_000_000 } }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 41_500_000n } + metric: { BestLapMicros: 41_500_000 } } ] } diff --git a/frontend/apps/rd-console/tests/marshaling.test.ts b/frontend/apps/rd-console/tests/marshaling.test.ts index c9e0ef3..c21d79d 100644 --- a/frontend/apps/rd-console/tests/marshaling.test.ts +++ b/frontend/apps/rd-console/tests/marshaling.test.ts @@ -12,17 +12,17 @@ import { describe('marshaling command builders', () => { it('voidDetectionCommand targets a log offset', () => { - expect(voidDetectionCommand(7n)).toEqual({ VoidDetection: { target: 7n } }); + expect(voidDetectionCommand(7)).toEqual({ VoidDetection: { target: 7 } }); }); it('insertLapCommand carries adapter, competitor, and source time', () => { - expect(insertLapCommand('rh-1', 'ALICE', 1_000_000n)).toEqual({ - InsertLap: { adapter: 'rh-1', competitor: 'ALICE', at: 1_000_000n } + expect(insertLapCommand('rh-1', 'ALICE', 1_000_000)).toEqual({ + InsertLap: { adapter: 'rh-1', competitor: 'ALICE', at: 1_000_000 } }); }); it('adjustLapCommand re-times a logged pass', () => { - expect(adjustLapCommand(3n, 2_000_000n)).toEqual({ AdjustLap: { target: 3n, at: 2_000_000n } }); + expect(adjustLapCommand(3, 2_000_000)).toEqual({ AdjustLap: { target: 3, at: 2_000_000 } }); }); it('voidHeatCommand voids the whole heat', () => { @@ -34,7 +34,7 @@ describe('marshaling command builders', () => { ApplyPenalty: { heat: 'heat-1', competitor: 'BOB', - penalty: { TimeAdded: { micros: 2_000_000n } } + penalty: { TimeAdded: { micros: 2_000_000 } } } }); }); @@ -46,6 +46,6 @@ describe('marshaling command builders', () => { }); it('converts whole seconds to microsecond SourceTime', () => { - expect(secondsToSourceTime(1.5)).toBe(1_500_000n); + expect(secondsToSourceTime(1.5)).toBe(1_500_000); }); }); diff --git a/frontend/apps/rd-console/tests/results.test.ts b/frontend/apps/rd-console/tests/results.test.ts index e361b55..6ca9682 100644 --- a/frontend/apps/rd-console/tests/results.test.ts +++ b/frontend/apps/rd-console/tests/results.test.ts @@ -23,7 +23,7 @@ describe('bracketFromOutcome', () => { describe('toExportJson', () => { it('serializes typed projection data with bigints as numbers', () => { - const json = toExportJson({ at: 1_000_000n }); + const json = toExportJson({ at: 1_000_000 }); expect(JSON.parse(json)).toEqual({ at: 1_000_000 }); }); }); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index d9a3a1d..2c77fed 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -42,7 +42,7 @@ describe('Session', () => { expect(controlFactory).toHaveBeenCalledWith('http://d.local', 'tok'); // Stream pushes a LiveRaceState body → session.liveState reflects it. - push({ body: { LiveRaceState: liveRunning }, cursor: 1n, status: 'live', error: undefined }); + push({ body: { LiveRaceState: liveRunning }, cursor: 1, status: 'live', error: undefined }); expect(session.connectionStatus).toBe('live'); expect(session.liveState?.current_heat).toBe('heat-1'); }); diff --git a/frontend/apps/rd-console/tests/setup.test.ts b/frontend/apps/rd-console/tests/setup.test.ts index 678bd7a..6573808 100644 --- a/frontend/apps/rd-console/tests/setup.test.ts +++ b/frontend/apps/rd-console/tests/setup.test.ts @@ -17,7 +17,7 @@ describe('setup config', () => { }); it('picks a sensible default win condition per format', () => { - expect(defaultWinCondition('timed-qual')).toEqual({ Timed: { window_micros: 120_000_000n } }); + expect(defaultWinCondition('timed-qual')).toEqual({ Timed: { window_micros: 120_000_000 } }); expect(defaultWinCondition('single-elim')).toEqual({ FirstToLaps: { n: 3 } }); expect(defaultWinCondition('zippyq')).toEqual({ BestConsecutive: { n: 3 } }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 62a1132..66bdaf0 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -45,7 +45,7 @@ export function makeTestSession(opts?: { ack?: CommandAck; live?: LiveRaceState session.login('http://d.local', 'tok'); const pushLive = (state: LiveRaceState) => - listener?.({ body: { LiveRaceState: state }, cursor: 1n, status: 'live', error: undefined }); + listener?.({ body: { LiveRaceState: state }, cursor: 1, status: 'live', error: undefined }); return { session, sendSpy, pushLive }; } diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts index 00b2126..291d12c 100644 --- a/frontend/packages/components/src/format.ts +++ b/frontend/packages/components/src/format.ts @@ -1,7 +1,7 @@ /** * Pure presentational formatters shared across the component library. * - * Durations on the wire are microseconds (`bigint`, source clock — see + * Durations on the wire are microseconds (a plain `number`, source clock — see * `@bindings/SourceTime` / `Lap.duration_micros`); these turn them into the * compact strings the widgets show. Kept framework-pure so components and tests * share one source of truth for "how a lap time reads". @@ -22,13 +22,12 @@ export function formatClock(ms: number): string { * Format a lap/split duration given in **microseconds** (the wire unit) as * `S.mmm` seconds, or minutes:seconds when ≥ 60s. `null`/`undefined` → `'—'`. */ -export function formatMicros(micros: bigint | number | null | undefined): string { +export function formatMicros(micros: number | null | undefined): string { if (micros === null || micros === undefined) return '—'; - // ts-rs types i64 micros as `bigint`, but serde sends i64 as a JSON *number*, so the - // runtime value is usually a `number`. Coerce to Number BEFORE any arithmetic — mixing - // a bigint and a number (e.g. `250000 / 1000n`) throws "Cannot mix BigInt and other types" - // and, thrown mid-render, aborts the update. (The wire-vs-type i64/bigint mismatch is a - // class to address systematically in the contract review.) + // Micros are a plain `number` now (the i64/u64 wire types render as TS `number`, + // matching serde's JSON-number output), so the bigint/number mismatch is resolved at + // the type level. The `Number()` coercion stays as belt-and-suspenders against any + // stray non-number value reaching a render. const totalMs = Math.floor(Number(micros) / 1000); if (totalMs >= 60000) return formatClock(totalMs); const seconds = Math.floor(totalMs / 1000); diff --git a/frontend/packages/components/tests/fixtures.ts b/frontend/packages/components/tests/fixtures.ts index 9f2220b..416168d 100644 --- a/frontend/packages/components/tests/fixtures.ts +++ b/frontend/packages/components/tests/fixtures.ts @@ -20,13 +20,13 @@ export const heatResult: HeatResult = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_250_000n } + metric: { BestLapMicros: 41_250_000 } }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 42_100_000n } + metric: { BestLapMicros: 42_100_000 } }, { competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, @@ -47,7 +47,7 @@ export const standings: RankEntry[] = [ export const pilotProgress: PilotProgress = { competitor: 'ALICE', laps_completed: 2, - last_lap_micros: 41_250_000n + last_lap_micros: 41_250_000 }; export const liveState: LiveRaceState = { @@ -55,8 +55,8 @@ export const liveState: LiveRaceState = { phase: 'Running', active_pilots: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[], progress: [ - { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000n }, - { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000n }, + { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000 }, + { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000 }, { competitor: 'CARMEN', laps_completed: 2, last_lap_micros: undefined } ], running_order: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[] diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts index 61049ac..c49bd5e 100644 --- a/frontend/packages/components/tests/format.test.ts +++ b/frontend/packages/components/tests/format.test.ts @@ -15,11 +15,11 @@ describe('formatClock', () => { describe('formatMicros', () => { it('renders sub-minute durations as S.mmm seconds', () => { - expect(formatMicros(41_250_000n)).toBe('41.250'); - expect(formatMicros(1_500_000n)).toBe('1.500'); + expect(formatMicros(41_250_000)).toBe('41.250'); + expect(formatMicros(1_500_000)).toBe('1.500'); }); it('rolls over to M:SS.mmm at or above a minute', () => { - expect(formatMicros(83_456_000n)).toBe('1:23.456'); + expect(formatMicros(83_456_000)).toBe('1:23.456'); }); it('renders null/undefined as an em dash', () => { expect(formatMicros(null)).toBe('—'); @@ -29,10 +29,10 @@ describe('formatMicros', () => { describe('formatMetric', () => { it('formats each win-condition metric variant', () => { - expect(formatMetric({ BestLapMicros: 41_250_000n } as Metric)).toBe('41.250'); - expect(formatMetric({ BestConsecutiveMicros: 120_000_000n } as Metric)).toBe('2:00.000'); + expect(formatMetric({ BestLapMicros: 41_250_000 } as Metric)).toBe('41.250'); + expect(formatMetric({ BestConsecutiveMicros: 120_000_000 } as Metric)).toBe('2:00.000'); expect(formatMetric({ BestLapMicros: null } as Metric)).toBe('—'); - expect(formatMetric({ LastLapAt: 5n } as Metric)).toBe('banked'); + expect(formatMetric({ LastLapAt: 5 } as Metric)).toBe('banked'); expect(formatMetric({ ReachedAt: null } as Metric)).toBe('—'); }); }); diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index d70cc6c..6906d36 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -19,9 +19,9 @@ const liveState = (phase: HeatPhase): ProjectionBody => ({ // A fetch mock that serves the given snapshots in order (sticking on the last) // and records every requested URL. Each response's `json()` yields a Snapshot -// whose cursor is rendered as a JSON number — exactly how serde renders the u64 -// `Cursor` on the wire — which the client coerces back to a bigint. -function mockFetch(snapshots: Array<{ cursor: bigint; body: ProjectionBody }>): { +// whose cursor is a JSON number — exactly how serde renders the u64 `Cursor` on the +// wire, and exactly the `number` the client now works with. +function mockFetch(snapshots: Array<{ cursor: number; body: ProjectionBody }>): { fetch: FetchLike; calls: string[]; } { @@ -34,7 +34,7 @@ function mockFetch(snapshots: Array<{ cursor: bigint; body: ProjectionBody }>): return { ok: true, status: 200, - json: async (): Promise => ({ cursor: Number(snap.cursor), body: snap.body }) + json: async (): Promise => ({ cursor: snap.cursor, body: snap.body }) } as unknown as Response; }; return { fetch, calls }; @@ -68,9 +68,9 @@ class MockWebSocket implements WebSocketLike { this.onopen?.call(this, {}); } emit(frame: unknown): void { - // Mirror the wire: serde renders the u64 Cursor as a JSON number, so emit - // bigints as numbers (JSON.stringify cannot serialize a bigint directly). - const data = JSON.stringify(frame, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); + // Mirror the wire: serde renders the u64 Cursor as a JSON number, which is just + // a plain `number` here — a straight JSON.stringify matches the wire. + const data = JSON.stringify(frame); this.onmessage?.call(this, { data }); } drop(): void { @@ -115,7 +115,7 @@ function manualTimer(): { }; } -const envelope = (sequence: bigint, phase: HeatPhase): ChangeEnvelope => ({ +const envelope = (sequence: number, phase: HeatPhase): ChangeEnvelope => ({ sequence, projection: 'LiveRaceState', change: { FreshValue: liveState(phase) } @@ -127,7 +127,7 @@ const envelope = (sequence: bigint, phase: HeatPhase): ChangeEnvelope => ({ * raw, unwrapped envelope was the shape these mocks used before, which masked the * client ignoring every real (wrapped) frame. */ -const change = (sequence: bigint, phase: HeatPhase) => ({ Change: envelope(sequence, phase) }); +const change = (sequence: number, phase: HeatPhase) => ({ Change: envelope(sequence, phase) }); // Let queued microtasks (the async snapshot fetch) settle. const flush = async (): Promise => { @@ -143,7 +143,7 @@ const phaseOf = (body: ProjectionBody | undefined): HeatPhase | undefined => describe('ProtocolClient', () => { it('fetches the scoped snapshot, then subscribes from its cursor', async () => { - const { fetch, calls } = mockFetch([{ cursor: 10n, body: liveState('Staged') }]); + const { fetch, calls } = mockFetch([{ cursor: 10, body: liveState('Staged') }]); const { factory, sockets } = mockWsFactory(); const client = connect({ @@ -157,7 +157,7 @@ describe('ProtocolClient', () => { // Snapshot was fetched (path-scoped, matching the server's routes) and applied. expect(calls).toHaveLength(1); expect(calls[0]).toContain('/snapshot/heat/heat-1'); - expect(client.getState().cursor).toBe(10n); + expect(client.getState().cursor).toBe(10); expect(phaseOf(client.getState().body)).toBe('Staged'); // A socket was opened; on open it sends a SubscribeRequest resuming from 10. @@ -177,38 +177,38 @@ describe('ProtocolClient', () => { // stream's `sequence` is its own axis starting at 1. The client must order the // stream by `sequence` — conflating it with the cursor (`seq <= 5` → "duplicate") // dropped the early stream and froze the live view against any non-empty log, - // which is every real Director snapshot. Earlier tests used cursor 0n, masking it. - const { fetch } = mockFetch([{ cursor: 5n, body: liveState('Scheduled') }]); + // which is every real Director snapshot. Earlier tests used cursor 0, masking it. + const { fetch } = mockFetch([{ cursor: 5, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); await flush(); sockets[0].open(); - sockets[0].emit(change(1n, 'Staged')); - sockets[0].emit(change(2n, 'Armed')); - sockets[0].emit(change(3n, 'Running')); + sockets[0].emit(change(1, 'Staged')); + sockets[0].emit(change(2, 'Armed')); + sockets[0].emit(change(3, 'Running')); // Every envelope applied → body converged. The resume cursor stays the snapshot // offset (the stream sequence is not a log offset). expect(phaseOf(client.getState().body)).toBe('Running'); - expect(client.getState().cursor).toBe(5n); + expect(client.getState().cursor).toBe(5); client.close(); }); it('is idempotent: re-delivered envelopes at/below the cursor are no-ops', async () => { - const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { fetch } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); await flush(); sockets[0].open(); - sockets[0].emit(change(1n, 'Staged')); - sockets[0].emit(change(2n, 'Armed')); + sockets[0].emit(change(1, 'Staged')); + sockets[0].emit(change(2, 'Armed')); // Re-deliver 1 and 2 (at-least-once): they're at/below the last applied sequence // (2), so they're no-ops and must not regress the state back to 'Scheduled'. - sockets[0].emit(change(1n, 'Scheduled')); - sockets[0].emit(change(2n, 'Scheduled')); + sockets[0].emit(change(1, 'Scheduled')); + sockets[0].emit(change(2, 'Scheduled')); expect(phaseOf(client.getState().body)).toBe('Armed'); @@ -217,23 +217,23 @@ describe('ProtocolClient', () => { it('re-snapshots on a sequence gap, then resumes from the fresh cursor', async () => { const { fetch, calls } = mockFetch([ - { cursor: 0n, body: liveState('Scheduled') }, // initial snapshot - { cursor: 5n, body: liveState('Running') } // re-snapshot after the gap + { cursor: 0, body: liveState('Scheduled') }, // initial snapshot + { cursor: 5, body: liveState('Running') } // re-snapshot after the gap ]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); await flush(); sockets[0].open(); - sockets[0].emit(change(1n, 'Staged')); + sockets[0].emit(change(1, 'Staged')); // Gap: jump to 4 (missed 2 and 3). The client must re-snapshot. - sockets[0].emit(change(4n, 'Armed')); + sockets[0].emit(change(4, 'Armed')); await flush(); // A second snapshot fetch happened and the old socket was torn down. expect(calls).toHaveLength(2); expect(sockets[0].closed).toBe(true); - expect(client.getState().cursor).toBe(5n); + expect(client.getState().cursor).toBe(5); expect(phaseOf(client.getState().body)).toBe('Running'); // A fresh socket re-subscribes from the new cursor (5). @@ -245,17 +245,17 @@ describe('ProtocolClient', () => { // The fresh subscription restarts the per-stream sequence, so its first envelope // is accepted and the body converges. The resume cursor stays the re-snapshot // offset (5). - sockets[1].emit(change(6n, 'Finished')); + sockets[1].emit(change(6, 'Finished')); expect(phaseOf(client.getState().body)).toBe('Finished'); - expect(client.getState().cursor).toBe(5n); + expect(client.getState().cursor).toBe(5); client.close(); }); it('re-snapshots when the server reports a stale cursor', async () => { const { fetch, calls } = mockFetch([ - { cursor: 100n, body: liveState('Staged') }, - { cursor: 200n, body: liveState('Running') } + { cursor: 100, body: liveState('Staged') }, + { cursor: 200, body: liveState('Running') } ]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); @@ -267,14 +267,14 @@ describe('ProtocolClient', () => { await flush(); expect(calls).toHaveLength(2); - expect(client.getState().cursor).toBe(200n); + expect(client.getState().cursor).toBe(200); expect(sockets).toHaveLength(2); client.close(); }); it('reconnects on socket drop and resumes from the last-applied cursor', async () => { - const { fetch, calls } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { fetch, calls } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); const timer = manualTimer(); const client = connect({ @@ -288,8 +288,8 @@ describe('ProtocolClient', () => { }); await flush(); sockets[0].open(); - sockets[0].emit(change(1n, 'Staged')); - sockets[0].emit(change(2n, 'Armed')); + sockets[0].emit(change(1, 'Staged')); + sockets[0].emit(change(2, 'Armed')); // Socket drops. sockets[0].drop(); @@ -312,14 +312,14 @@ describe('ProtocolClient', () => { expect(client.getState().status).toBe('live'); // The resumed subscription restarts the sequence; its first envelope converges. - sockets[1].emit(change(1n, 'Running')); + sockets[1].emit(change(1, 'Running')); expect(phaseOf(client.getState().body)).toBe('Running'); client.close(); }); it('notifies onState listeners and stops after close', async () => { - const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]); + const { fetch } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); @@ -327,14 +327,14 @@ describe('ProtocolClient', () => { const unsub = client.onState((s) => seen.push(phaseOf(s.body))); await flush(); sockets[0].open(); - sockets[0].emit(change(1n, 'Staged')); + sockets[0].emit(change(1, 'Staged')); expect(seen).toContain('Scheduled'); expect(seen).toContain('Staged'); unsub(); const before = seen.length; - sockets[0].emit(change(2n, 'Armed')); + sockets[0].emit(change(2, 'Armed')); expect(seen.length).toBe(before); // unsubscribed: no more notifications client.close(); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 5caab31..48c0985 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -119,41 +119,12 @@ export interface ProtocolClient { // externally tagged: `{ "Change": ChangeEnvelope }` | `{ "ReSnapshotRequired": ProtocolError }`. // A bare `ProtocolError` may also arrive (e.g. a VersionMismatch just before close). -// ── Cursor (bigint) wire handling ────────────────────────────────────────────── +// ── Cursor wire handling ─────────────────────────────────────────────────────── // -// `Cursor` is a u64 rendered as a TS `bigint` (bindings/Cursor.ts). On the wire it -// arrives as a JSON number or string depending on the server's serializer; `bigint` -// values, conversely, are not serializable by `JSON.stringify`. These two helpers -// bracket that mismatch so cursors stay precise (a u64 can exceed JS's safe-integer -// range) and the rest of the client works in `bigint`. - -/** Coerce a wire cursor (number | string | bigint) to a `bigint`. */ -function toCursor(v: unknown): Cursor { - if (typeof v === 'bigint') return v; - if (typeof v === 'number') return BigInt(Math.trunc(v)); - if (typeof v === 'string' && v.length > 0) return BigInt(v); - throw new Error(`invalid cursor: ${String(v)}`); -} - -/** - * `JSON.stringify` with bigints rendered as JSON numbers (serde's u64 default), - * since `JSON.stringify` cannot serialize a bigint directly. Cursors past 2^53 - * lose precision in this number form; if a deployment ever needs full-u64 cursors - * on the wire the server would serialize them as strings and this would follow. - */ -function stringifyWire(value: unknown): string { - return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? Number(v) : v)); -} - -/** Normalize a parsed snapshot so its cursor is a `bigint`. */ -function normalizeSnapshot(data: Snapshot): Snapshot { - return { ...data, cursor: toCursor((data as { cursor: unknown }).cursor) }; -} - -/** Normalize a parsed envelope so its sequence is a `bigint`. */ -function normalizeEnvelope(env: ChangeEnvelope): ChangeEnvelope { - return { ...env, sequence: toCursor((env as { sequence: unknown }).sequence) }; -} +// `Cursor` is a u64 rendered as a plain TS `number` (bindings/Cursor.ts). Our +// cursors/sequences are bounded well below 2^53, and serde serialises them as JSON +// numbers, so they arrive already as `number`s — no coercion or custom stringifier +// is needed. const isProtocolError = (v: unknown): v is ProtocolError => typeof v === 'object' && @@ -231,7 +202,7 @@ export function connect(options: ConnectOptions): ProtocolClient { // The per-stream `sequence` axis (protocol.html §3/§9.5): starts at 1 on each // subscription, distinct from `cursor`. Reset to 0 on every (re)subscribe so the // first envelope is accepted whatever the snapshot cursor's value. - let streamSeq: Cursor = 0n; + let streamSeq: Cursor = 0; let status: ConnectionStatus = 'connecting'; let lastError: ProtocolError | undefined; @@ -287,7 +258,7 @@ export function connect(options: ConnectOptions): ProtocolClient { fail(err); return false; } - const data = normalizeSnapshot((await resp.json()) as Snapshot); + const data = (await resp.json()) as Snapshot; if (gen !== generation || closed) return false; body = data.body; cursor = data.cursor; @@ -307,11 +278,11 @@ export function connect(options: ConnectOptions): ProtocolClient { // (protocol.html §3/§9.5); conflating them drops the early stream as bogus // "duplicates" and freezes the live view. `streamSeq === 0` means this is the // first envelope of a fresh subscription, so accept it whatever its value. - if (streamSeq !== 0n) { + if (streamSeq !== 0) { // Idempotent, keyed by sequence: anything at or below the last applied is a no-op. if (seq <= streamSeq) return 'duplicate'; // The stream is contiguous: the next envelope must be exactly +1. - if (seq !== streamSeq + 1n) return 'gap'; + if (seq !== streamSeq + 1) return 'gap'; } const change = env.change; if ('FreshValue' in change) { @@ -333,7 +304,7 @@ export function connect(options: ConnectOptions): ProtocolClient { if (gen !== generation || closed) return; // A fresh subscription: the server restarts the per-stream sequence at 1, so // reset our tracker to accept it from the top. - streamSeq = 0n; + streamSeq = 0; setStatus('subscribing'); const url = token ? `${wsBase}/stream?token=${encodeURIComponent(token)}` : `${wsBase}/stream`; let socket: WebSocketLike; @@ -348,7 +319,7 @@ export function connect(options: ConnectOptions): ProtocolClient { socket.onopen = () => { if (gen !== generation || closed) return; const req: SubscribeRequest = { scope, from: cursor }; - socket.send(stringifyWire(req)); + socket.send(JSON.stringify(req)); setStatus('live'); }; @@ -378,7 +349,7 @@ export function connect(options: ConnectOptions): ProtocolClient { // `StreamMessage::Change(envelope)` — the common case: unwrap + apply. if (isStreamChange(parsed)) { - const result = applyEnvelope(normalizeEnvelope(parsed.Change)); + const result = applyEnvelope(parsed.Change); if (result === 'gap') { // Missed envelopes the stream can't replay → re-snapshot and re-subscribe. await resnapshot(gen); From 2c8d2eaded93cf05c6a6a0b0384ef5d34cc1c272 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:10:30 +0000 Subject: [PATCH 026/362] Add full-stack observability harness for the RD console e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operationalize the team's "full-stack observability FIRST" debugging workflow: boot the real Director and capture the WHOLE stack's output — browser console, page errors, WS frames, and the Director's server log — in one place, so any UI bug shows its true picture immediately. - frontend/test-harness/director.ts: reusable, framework-agnostic Director-boot harness. `startDirector(opts?) -> { baseUrl, token, readLogs(), stop() }` spawns the built `gridfpv` binary on an ephemeral (or given) port with a pinned RD token + sim env, buffers its whole stdout+stderr, polls `/health` until serving, and exposes the captured logs + a clean shutdown. Builds the binary on demand. Usable from both vitest (the upcoming Node contract suite) and Playwright. - frontend/e2e/observability.ts: Playwright fixture. Importing `test` from here auto-captures `page.on('console')`, `page.on('pageerror')`, `page.on('websocket')` frames, and the Director's server log, and on failure dumps all four together to the test output (and attaches it to the trace/report). A render-time crash like the BigInt-in-render bug surfaces as a PAGEERROR with no ad-hoc logging. The Director is a worker-scoped fixture; `baseURL` points at it. - frontend/e2e/global-setup.ts + playwright.config.ts: drop the `webServer` (the fixture boots the Director now) and build the SPA `dist/` once in global setup. - frontend/e2e/race.spec.ts: adopt the observability `test` so the existing race e2e fails loud (full-stack dump) if it ever breaks. - frontend/test-harness/observe.ts + `observe` npm script + README: manual entry point to boot the Director, stream its log, and drive a sim race by hand. Adds `tsx` devDependency to run the TS entry point. Verified: e2e 1 passed under the new fixture; a forced transient failure showed the PAGEERROR + WS frames + server-log tail together in the dump, then reverted. `npm run build`/`check`/`lint`/`test` and `cargo xtask ci` all green. Part of #13 (v0.4 observability harness). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/e2e/global-setup.ts | 24 ++ frontend/e2e/observability.ts | 199 ++++++++++++ frontend/e2e/race.spec.ts | 12 +- frontend/package-lock.json | 504 ++++++++++++++++++++++++++++++ frontend/package.json | 4 +- frontend/playwright.config.ts | 61 +--- frontend/test-harness/README.md | 90 ++++++ frontend/test-harness/director.ts | 184 +++++++++++ frontend/test-harness/observe.ts | 72 +++++ 9 files changed, 1100 insertions(+), 50 deletions(-) create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/observability.ts create mode 100644 frontend/test-harness/README.md create mode 100644 frontend/test-harness/director.ts create mode 100644 frontend/test-harness/observe.ts diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..af9c88b --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,24 @@ +/** + * Playwright global setup (#13, v0.4 observability harness). + * + * The worker-scoped `director` fixture serves the built RD console SPA (`GRIDFPV_ASSETS`), so the + * SPA `dist/` must exist before any worker boots a Director. `npm run build` builds every + * workspace, so the rd-console app gets its freshly built `@gridfpv/*` deps. We run it once here. + * + * The Director binary itself is built on demand by the boot harness (`startDirector`). + */ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const frontendRoot = fileURLToPath(new URL('.', import.meta.url)).replace(/\/e2e\/?$/, ''); + +export default function globalSetup(): void { + const built = spawnSync('npm', ['run', 'build'], { + cwd: resolve(frontendRoot), + stdio: 'inherit' + }); + if (built.status !== 0) { + throw new Error('frontend build failed (npm run build) — cannot serve the RD console SPA'); + } +} diff --git a/frontend/e2e/observability.ts b/frontend/e2e/observability.ts new file mode 100644 index 0000000..63ae37a --- /dev/null +++ b/frontend/e2e/observability.ts @@ -0,0 +1,199 @@ +/** + * Playwright observability fixture (#13, v0.4 observability harness). + * + * The team's debugging rule is *full-stack observability FIRST*: when a UI breaks, you should see + * the WHOLE stack's picture at once — what the browser logged, what JS blew up in the page, what + * the Director's server log said, and what crossed the WebSocket — without anyone having added + * ad-hoc `console.log`s after the fact. This fixture operationalizes that. + * + * Importing `test` from here (instead of `@playwright/test`) gives every spec, for free: + * • `page.on('console')` — every browser console message (log/info/warn/**error**), + * • `page.on('pageerror')` — every uncaught exception / unhandled rejection in the page + * (this is the one that catches a render-time crash like the BigInt-in-render bug — it + * surfaces as a `PAGEERROR` line in the dump with no extra logging), + * • `page.on('websocket')` — the protocol WS: open/close + a tail of sent/received frames, + * • the Director's **server log** — the boot harness buffers the binary's stdout+stderr. + * + * On a **failing** test it dumps all four streams, together, to the test output (and attaches the + * same report to the Playwright trace/report). On success it stays quiet. + * + * The Director is a **worker-scoped** fixture: one real `gridfpv` binary per worker, serving the + * built RD console SPA, reused across the specs in that worker and torn down at the end. `baseURL` + * is overridden to point at it, so specs just `page.goto('/')`. + */ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as base, expect } from '@playwright/test'; +import { type Director, startDirector } from '../test-harness/director.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const frontendRoot = resolve(here, '..'); +const dist = resolve(frontendRoot, 'apps', 'rd-console', 'dist'); + +/** Fast sim defaults for the e2e: holeshot + 4 laps @ 250ms, same as the standalone config. */ +const SIM_LAPS = Number(process.env.GRIDFPV_SIM_LAPS ?? 4); +const SIM_LAP_MS = Number(process.env.GRIDFPV_SIM_LAP_MS ?? 250); + +/** One captured browser console message. */ +interface ConsoleLine { + ts: number; + type: string; + text: string; +} +/** One captured page error (uncaught exception / unhandled rejection in the page). */ +interface PageErrorLine { + ts: number; + text: string; +} +/** One captured WS frame or lifecycle event. */ +interface WsLine { + ts: number; + kind: 'open' | 'close' | 'sent' | 'received'; + url: string; + payload?: string; +} + +/** Keep the WS frame tail bounded — a long sim race emits a lot of change envelopes. */ +const WS_FRAME_CAP = 80; + +function clip(s: string, max = 400): string { + return s.length > max ? `${s.slice(0, max)}… (${s.length} chars)` : s; +} + +/** The per-test capture buffers the fixtures expose. */ +interface Observability { + console: ConsoleLine[]; + pageErrors: PageErrorLine[]; + ws: WsLine[]; +} + +interface WorkerFixtures { + director: Director; +} + +interface TestFixtures { + observability: Observability; +} + +export const test = base.extend({ + // ── One real Director per worker: builds + boots the binary, serves the built SPA ────────── + director: [ + // Playwright requires the object-destructuring pattern here to extract fixture deps; + // this fixture has none, so the pattern is empty. + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const director = await startDirector({ + token: 'test-rd-token', + assets: dist, + simLaps: SIM_LAPS, + simLapMs: SIM_LAP_MS + }); + await use(director); + await director.stop(); + }, + { scope: 'worker' } + ], + + // Point every spec's navigation at the worker's Director. + baseURL: async ({ director }, use) => { + await use(director.baseUrl); + }, + + // ── Per-test full-stack capture + on-failure dump ────────────────────────────────────────── + observability: [ + async ({ page, director }, use, testInfo) => { + const obs: Observability = { console: [], pageErrors: [], ws: [] }; + + page.on('console', (msg) => { + obs.console.push({ ts: Date.now(), type: msg.type(), text: msg.text() }); + }); + page.on('pageerror', (err) => { + obs.pageErrors.push({ ts: Date.now(), text: err.stack ?? String(err) }); + }); + // Tap the protocol WebSocket: lifecycle + a bounded tail of frames both directions. + page.on('websocket', (wsConn) => { + const url = wsConn.url(); + obs.ws.push({ ts: Date.now(), kind: 'open', url }); + wsConn.on('framesent', (f) => { + if (obs.ws.length < WS_FRAME_CAP) { + obs.ws.push({ ts: Date.now(), kind: 'sent', url, payload: clip(String(f.payload)) }); + } + }); + wsConn.on('framereceived', (f) => { + if (obs.ws.length < WS_FRAME_CAP) { + obs.ws.push({ + ts: Date.now(), + kind: 'received', + url, + payload: clip(String(f.payload)) + }); + } + }); + wsConn.on('close', () => obs.ws.push({ ts: Date.now(), kind: 'close', url })); + }); + + await use(obs); + + // After the test body: if it failed, dump the whole stack's picture together. + const failed = testInfo.status !== testInfo.expectedStatus; + if (failed) { + const dump = renderDump(obs, director.readLogs()); + // To the console (visible in `list`/`line` reporters) … + console.error(dump); + // … and attached to the trace/HTML report for after-the-fact inspection. + await testInfo.attach('full-stack-observability', { + body: dump, + contentType: 'text/plain' + }); + } + }, + { auto: true } + ] +}); + +/** Render the four captured streams into one ordered, readable failure dump. */ +function renderDump(obs: Observability, serverLog: string): string { + const lines: string[] = []; + const rule = '═'.repeat(78); + lines.push(''); + lines.push(rule); + lines.push(' FULL-STACK OBSERVABILITY DUMP (test failed — showing the whole picture)'); + lines.push(rule); + + lines.push(''); + lines.push(`── BROWSER CONSOLE (${obs.console.length}) ────────────────────────────────────────`); + if (obs.console.length === 0) lines.push(' (none)'); + for (const c of obs.console) { + lines.push(` [${c.type.toUpperCase()}] ${clip(c.text, 500)}`); + } + + lines.push(''); + lines.push( + `── PAGE ERRORS (${obs.pageErrors.length}) ──────────────────────────────────────────` + ); + if (obs.pageErrors.length === 0) lines.push(' (none)'); + for (const e of obs.pageErrors) { + lines.push(` [PAGEERROR] ${clip(e.text, 1500)}`); + } + + lines.push(''); + lines.push( + `── WS FRAMES (${obs.ws.length}${obs.ws.length >= WS_FRAME_CAP ? '+, capped' : ''}) ──────────────────────────────────` + ); + if (obs.ws.length === 0) lines.push(' (none)'); + for (const w of obs.ws) { + const arrow = w.kind === 'sent' ? '→' : w.kind === 'received' ? '←' : '·'; + lines.push(` ${arrow} [${w.kind.toUpperCase()}] ${w.payload ?? w.url}`); + } + + lines.push(''); + lines.push('── DIRECTOR SERVER LOG (tail) ────────────────────────────────────────────────'); + const tail = serverLog.split('\n').slice(-40).join('\n'); + lines.push(tail.length ? tail : ' (empty)'); + + lines.push(rule); + lines.push(''); + return lines.join('\n'); +} + +export { expect }; diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 94fb67e..1507c91 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -1,5 +1,6 @@ /** - * Full RD console click-through against a real Director (#13, v0.4 Director wiring). + * Full RD console click-through against a real Director (#13, v0.4 Director wiring + + * observability harness). * * This is the deliverable proof: a person opens the RD console, logs in, defines a heat * with named pilots, runs it, **watches the live laps climb in the rendered DOM**, finishes @@ -12,10 +13,13 @@ * proving the live read stream (protocol-client) and the reactive `Session` push updates * all the way through the real render path. * - * The Director (built console SPA + known RD token + fast sim) is stood up by the config's - * `webServer`; `baseURL` points at it. + * The Director (built console SPA + known RD token + fast sim) is a worker-scoped fixture in + * `./observability.ts` (boot harness: `../test-harness/director.ts`); `baseURL` points at it. + * Importing `test`/`expect` from `./observability.js` (not `@playwright/test`) means this spec + * now fails LOUD: if it ever breaks, the failure output carries the full-stack dump — browser + * console, page errors, WS frames, and the Director's server log — together. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from './observability.js'; import { RD_TOKEN } from '../playwright.config.js'; const PILOTS = ['Ace', 'Bee', 'Cee']; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5a9c2f..521c443 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "svelte": "^5.16.0", "svelte-check": "^4.1.1", "svelte-eslint-parser": "^0.43.0", + "tsx": "^4.22.4", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1" } @@ -4503,6 +4504,509 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7778c33..c162efe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,8 @@ "format": "prettier --write .", "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console", "e2e": "playwright test", - "e2e:install": "playwright install chromium" + "e2e:install": "playwright install chromium", + "observe": "tsx test-harness/observe.ts" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -33,6 +34,7 @@ "svelte": "^5.16.0", "svelte-check": "^4.1.1", "svelte-eslint-parser": "^0.43.0", + "tsx": "^4.22.4", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1" } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index d88bbc3..1172ba1 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,38 +1,29 @@ /** - * Playwright config for the RD console e2e (#13, v0.4 Director wiring). + * Playwright config for the RD console e2e (#13, v0.4 Director wiring + observability harness). * - * One spec drives the **full RD click-through in real headless chromium** — log in, define - * a heat with named pilots, run it, watch the live laps climb in the rendered DOM, finish, - * score, read results — against a **real** Director (`gridfpv` binary, built-in sim source). + * The specs drive the **full RD click-through in real headless chromium** — log in, define a heat + * with named pilots, run it, watch the live laps climb in the rendered DOM, finish, score, read + * results — against a **real** Director (`gridfpv` binary, built-in sim source). * - * `webServer` stands the whole stack up for the run, in order, on a fixed port: - * 1. `npm run build` the console (and its workspace deps) so `dist/` matches the source; - * 2. launch the Director with a *known* RD token (`GRIDFPV_RD_TOKEN`) and a fast sim - * (4 laps @ 250ms) serving that `dist/` as its SPA (`GRIDFPV_ASSETS`). - * The Director serves the SPA at its own origin, so the spec navigates straight at it — - * one origin, the real control path, the real sim laps. Playwright waits on `/health`. + * The Director is no longer stood up by a `webServer` here; it is a **worker-scoped fixture** in + * `e2e/observability.ts` (backed by the reusable `test-harness/director.ts` boot harness), so the + * same boot path is shared with the Node-only contract suite and so every spec automatically taps + * the Director's server log into its on-failure dump. We only need to ensure the SPA `dist/` is + * built before the run — that is this config's `globalSetup`. * - * Headless chromium only: the console is the dense desktop surface; this proves the - * click-through and the live render path, not cross-browser rendering. + * Headless chromium only: the console is the dense desktop surface; this proves the click-through + * and the live render path, not cross-browser rendering. */ -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { defineConfig, devices } from '@playwright/test'; -const here = fileURLToPath(new URL('.', import.meta.url)); -const frontendRoot = here; -const repoRoot = resolve(frontendRoot, '..'); -const dist = resolve(frontendRoot, 'apps', 'rd-console', 'dist'); -const director = resolve(repoRoot, 'target', 'debug', 'gridfpv'); - -// A fixed, loopback-only port + the known RD token the spec logs in with. -const PORT = 8123; -const BASE_URL = `http://127.0.0.1:${PORT}`; +// The known RD token the observability fixture pins on the Director and the specs log in with. export const RD_TOKEN = 'test-rd-token'; export default defineConfig({ testDir: './e2e', testMatch: '**/*.spec.ts', + // Build the SPA the worker-scoped Director will serve, before any worker boots one. + globalSetup: './e2e/global-setup.ts', // A real sim race takes a few seconds across its phases; give generous headroom. timeout: 90_000, expect: { timeout: 15_000 }, @@ -42,29 +33,9 @@ export default defineConfig({ retries: 0, reporter: process.env.CI ? 'line' : 'list', use: { - baseURL: BASE_URL, + // baseURL is provided per-worker by the `director` fixture (its ephemeral URL). headless: true, trace: 'retain-on-failure' }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - webServer: { - // Build the console — `npm run build` builds every workspace, so the rd-console SPA - // gets its freshly built `@gridfpv/*` deps — then exec the real Director on $PORT, - // serving that SPA with a known token and a fast sim. `exec` replaces the shell with the - // Director so Playwright's process tree can stop it cleanly. - command: `npm run build && exec ${JSON.stringify(director)}`, - cwd: frontendRoot, - url: `${BASE_URL}/health`, - timeout: 180_000, - reuseExistingServer: !process.env.CI, - stdout: 'pipe', - stderr: 'pipe', - env: { - GRIDFPV_ADDR: `127.0.0.1:${PORT}`, - GRIDFPV_RD_TOKEN: RD_TOKEN, - GRIDFPV_SIM_LAPS: '4', - GRIDFPV_SIM_LAP_MS: '250', - GRIDFPV_ASSETS: dist - } - } + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }] }); diff --git a/frontend/test-harness/README.md b/frontend/test-harness/README.md new file mode 100644 index 0000000..0dcea67 --- /dev/null +++ b/frontend/test-harness/README.md @@ -0,0 +1,90 @@ +# GridFPV observability harness + +> Full-stack observability **first**. When a UI breaks, see the WHOLE stack's picture at once — +> browser console, page errors, the Director's server log, and what crossed the WebSocket — with +> nobody adding ad-hoc `console.log`s after the fact. This directory is that harness. (#13, v0.4) + +It boots the **real** Director (the `gridfpv` binary — same protocol API + RD console SPA an +operator runs) and captures the full stack's output in one place, for both automated tests and +hands-on debugging. + +## Pieces + +| File | What it is | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `director.ts` | Reusable Director-boot harness: `startDirector(opts?) -> { baseUrl, token, readLogs(), stop() }`. Framework-agnostic — usable from **vitest** (Node, no browser — the upcoming contract suite) and **Playwright** alike. | +| `../e2e/observability.ts` | Playwright fixture: `import { test, expect } from './observability.js'` and every spec auto-captures browser console + page errors + WS frames + the Director's server log, and **dumps them all together on failure**. | +| `../e2e/global-setup.ts` | Playwright global setup: builds the SPA `dist/` the worker Director serves. | +| `observe.ts` | Manual entry point — boot a Director, stream its server log, print the console URL + token, drive a sim race by hand. | + +## `startDirector(opts?)` + +Spawns the built binary on an ephemeral (or given) port with a pinned RD token + sim env, buffers +its whole stdout+stderr, waits until it serves `GET /health`, and returns a handle. Builds the +binary on demand (`cargo build -p gridfpv-app`) if missing. + +```ts +import { startDirector } from './test-harness/director.js'; + +const d = await startDirector({ simLaps: 4, simLapMs: 250 }); // ephemeral port, fresh token +// d.baseUrl → "http://127.0.0.1:54123" +// d.token → the pinned RD control token (also printed in the log) +// d.readLogs() → everything the Director has printed so far +await d.stop(); +``` + +Options (all optional): `port`, `token`, `assets` (built SPA `dist/` for `GRIDFPV_ASSETS`), +`simLaps`, `simLapMs`, `env`, `build` (default `true`), `readyTimeoutMs` (default `30_000`). + +The **contract suite** (next, vitest) should reuse `startDirector` verbatim: it boots the same +real Director, no browser, and `readLogs()` is the same server-log seam to assert against / dump. + +## The Playwright on-failure dump + +A failing spec prints (and attaches to the trace/report) one block: + +``` +══════════════════════════════════════════════════════════════════════════════ + FULL-STACK OBSERVABILITY DUMP (test failed — showing the whole picture) +══════════════════════════════════════════════════════════════════════════════ + +── BROWSER CONSOLE (N) ───────────────────────────── + [ERROR] ... +── PAGE ERRORS (N) ───────────────────────────────── + [PAGEERROR] Error: ... Cannot mix BigInt and other types ... +── WS FRAMES (N) ─────────────────────────────────── + · [OPEN] ws://127.0.0.1:39405/stream?token=... + → [SENT] {"scope":{"Event":{"event":"event"}},"from":0} + ← [RECEIVED] {...change envelope...} +── DIRECTOR SERVER LOG (tail) ────────────────────── + GridFPV Director 0.1.0 — serving ... +══════════════════════════════════════════════════════════════════════════════ +``` + +A render-time crash like the BigInt-in-render bug surfaces as a `[PAGEERROR]` line here with no +extra logging — that is the point. + +## Manual observe + +```sh +cd frontend +npm run build # build the SPA the Director serves (else it serves the API only) +npm run observe # boot the Director, stream its log, print the console URL + token +``` + +Then open the printed `RD console` URL, sign in with that address + token, and drive a heat by +hand. Server log streams in the terminal; open browser devtools for the browser console — the two +halves of the stack, side by side. Ctrl-C to stop. + +Env overrides: `GRIDFPV_PORT` (default `8123`), `GRIDFPV_RD_TOKEN`, `GRIDFPV_SIM_LAPS`, +`GRIDFPV_SIM_LAP_MS`. + +## Running the e2e + +Needs chromium's system libs on this host (see how the e2e runs): + +```sh +cd frontend +export LD_LIBRARY_PATH=/tmp/pwlibs/root/usr/lib/x86_64-linux-gnu:/tmp/pwlibs/root/usr/lib/x86_64-linux-gnu/gbm +npm run e2e +``` diff --git a/frontend/test-harness/director.ts b/frontend/test-harness/director.ts new file mode 100644 index 0000000..8d91c48 --- /dev/null +++ b/frontend/test-harness/director.ts @@ -0,0 +1,184 @@ +/** + * Reusable Director-boot harness (#13, v0.4 observability harness). + * + * `startDirector(opts?)` spawns the **real** built `gridfpv` binary — the actual Director that + * serves the protocol API + the RD console SPA — on an ephemeral (or given) port, buffers its + * whole stdout+stderr, waits until it is actually serving (polls `GET /health`), and hands back + * a small handle: the `baseUrl` to point a client at, the known RD `token`, `readLogs()` to read + * everything the Director has printed so far, and `stop()` for a clean shutdown. + * + * It is deliberately framework-agnostic — no Playwright, no vitest imports — so it works equally + * from the browser e2e (the Playwright observability fixture stands one up and taps its logs into + * the failure dump) and from the upcoming Node-only contract suite (vitest), which drives the + * protocol directly with no browser at all. + * + * The binary is built on demand (`cargo build -p gridfpv-app`) if it is missing, so a fresh + * checkout / CI run just works; pass `build: false` to require a pre-built binary. + */ +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = resolve(here, '..'); +const repoRoot = resolve(frontendRoot, '..'); + +/** Absolute path to the built Director binary the harness boots. */ +export const DIRECTOR_BIN = resolve(repoRoot, 'target', 'debug', 'gridfpv'); + +/** Options for {@link startDirector}. All optional — the defaults give a fast in-memory sim. */ +export interface StartDirectorOptions { + /** Port to bind. Default: an ephemeral free port picked by the OS. */ + port?: number; + /** The known RD control token to pin (`GRIDFPV_RD_TOKEN`). Default: a fresh per-call token. */ + token?: string; + /** Built RD console `dist/` dir to serve as the SPA (`GRIDFPV_ASSETS`). Default: unset (API only). */ + assets?: string; + /** Number of sim laps per heat (`GRIDFPV_SIM_LAPS`). Default: `4`. */ + simLaps?: number; + /** Sim lap duration in ms (`GRIDFPV_SIM_LAP_MS`). Default: `250`. */ + simLapMs?: number; + /** Extra env to layer onto the Director process. */ + env?: Record; + /** Build the binary first if it is missing. Default: `true`. */ + build?: boolean; + /** Max ms to wait for `/health` to come up. Default: `30_000`. */ + readyTimeoutMs?: number; +} + +/** A running Director plus the captured output and a clean shutdown. */ +export interface Director { + /** Base URL the protocol/SPA is served at, e.g. `http://127.0.0.1:54123`. */ + readonly baseUrl: string; + /** The RD control token registered for this run (the one printed in the logs). */ + readonly token: string; + /** Everything the Director has printed to stdout+stderr so far, in order. */ + readLogs(): string; + /** Stop the Director and resolve once its process has exited. Idempotent. */ + stop(): Promise; +} + +/** Pick a free TCP port by binding `:0` and reading back the OS-assigned port. */ +function pickFreePort(): Promise { + return new Promise((resolvePort, reject) => { + const srv = createServer(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (addr && typeof addr === 'object') { + const { port } = addr; + srv.close(() => resolvePort(port)); + } else { + srv.close(() => reject(new Error('could not determine a free port'))); + } + }); + }); +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Build, spawn, and await a ready Director. Resolves once `GET /health` returns 200; rejects + * (after dumping the captured logs into the error) if the process dies or never becomes ready. + */ +export async function startDirector(opts: StartDirectorOptions = {}): Promise { + const { + token = `harness-rd-${Math.random().toString(36).slice(2, 10)}`, + assets, + simLaps = 4, + simLapMs = 250, + env = {}, + build = true, + readyTimeoutMs = 30_000 + } = opts; + + // Build the binary on demand so a fresh checkout / CI just works. + if (build && !existsSync(DIRECTOR_BIN)) { + const built = spawnSync('cargo', ['build', '-p', 'gridfpv-app'], { + cwd: repoRoot, + stdio: 'inherit' + }); + if (built.status !== 0) { + throw new Error(`failed to build the Director binary (cargo build -p gridfpv-app)`); + } + } + if (!existsSync(DIRECTOR_BIN)) { + throw new Error( + `Director binary not found at ${DIRECTOR_BIN} — run \`cargo build -p gridfpv-app\` (or pass build: true).` + ); + } + + const port = opts.port ?? (await pickFreePort()); + const baseUrl = `http://127.0.0.1:${port}`; + + // One growing buffer of everything the Director prints; `readLogs()` snapshots it. + let logBuffer = ''; + const append = (chunk: Buffer): void => { + logBuffer += chunk.toString('utf8'); + }; + + const child: ChildProcess = spawn(DIRECTOR_BIN, [], { + cwd: repoRoot, + env: { + ...process.env, + GRIDFPV_ADDR: `127.0.0.1:${port}`, + GRIDFPV_RD_TOKEN: token, + GRIDFPV_SIM_LAPS: String(simLaps), + GRIDFPV_SIM_LAP_MS: String(simLapMs), + ...(assets ? { GRIDFPV_ASSETS: assets } : {}), + ...env + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + child.stdout?.on('data', append); + child.stderr?.on('data', append); + + // If the process exits before/while we wait for ready, surface it as a rejected ready-wait. + let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null; + child.on('exit', (code, signal) => { + exited = { code, signal }; + }); + + const readLogs = (): string => logBuffer; + + const stop = async (): Promise => { + if (exited || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolveStop) => { + child.once('exit', () => resolveStop()); + child.kill('SIGTERM'); + // Hard-kill if it ignores SIGTERM. + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 5_000).unref?.(); + }); + }; + + // Poll /health until the Director is actually serving. + const deadline = Date.now() + readyTimeoutMs; + for (;;) { + if (exited) { + throw new Error( + `Director exited before becoming ready (code=${exited.code}, signal=${exited.signal}).\n` + + `--- Director log ---\n${logBuffer}` + ); + } + try { + const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2_000) }); + if (res.ok) break; + } catch { + // not up yet + } + if (Date.now() > deadline) { + await stop(); + throw new Error( + `Director did not serve /health within ${readyTimeoutMs}ms at ${baseUrl}.\n` + + `--- Director log ---\n${logBuffer}` + ); + } + await sleep(150); + } + + return { baseUrl, token, readLogs, stop }; +} diff --git a/frontend/test-harness/observe.ts b/frontend/test-harness/observe.ts new file mode 100644 index 0000000..833fcb5 --- /dev/null +++ b/frontend/test-harness/observe.ts @@ -0,0 +1,72 @@ +/** + * Manual "observe" entry point (#13, v0.4 observability harness). + * + * Hands-on, full-stack debugging without a test: boot the real Director (serving the built RD + * console SPA) with the same harness the e2e uses, stream its server log live to this terminal, + * and print the console URL + RD token so you can open a browser and drive a sim race by hand — + * server log here, browser console in the browser devtools, side by side. + * + * Run it via the frontend-root `observe` npm script: `npm run observe` + * (build the SPA first — `npm run build` — or the Director serves the API only). + * + * Env passthrough: GRIDFPV_RD_TOKEN, GRIDFPV_ADDR's port (GRIDFPV_PORT), GRIDFPV_SIM_LAPS, + * GRIDFPV_SIM_LAP_MS all override the defaults below. + */ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DIRECTOR_BIN, startDirector } from './director.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const frontendRoot = resolve(here, '..'); +const dist = resolve(frontendRoot, 'apps', 'rd-console', 'dist'); + +async function main(): Promise { + const port = process.env.GRIDFPV_PORT ? Number(process.env.GRIDFPV_PORT) : 8123; + const token = process.env.GRIDFPV_RD_TOKEN ?? 'observe-rd-token'; + + console.log('Booting the Director (this is the real `gridfpv` binary)…'); + console.log(` binary : ${DIRECTOR_BIN}`); + console.log(` assets : ${dist}`); + + const director = await startDirector({ + port, + token, + assets: dist, + simLaps: process.env.GRIDFPV_SIM_LAPS ? Number(process.env.GRIDFPV_SIM_LAPS) : 4, + simLapMs: process.env.GRIDFPV_SIM_LAP_MS ? Number(process.env.GRIDFPV_SIM_LAP_MS) : 600 + }); + + console.log(''); + console.log('────────────────────────────────────────────────────────────────────'); + console.log(` RD console : ${director.baseUrl}/`); + console.log(` RD token : ${director.token}`); + console.log(' Open the console URL, sign in with the address + token above, and'); + console.log(' drive a heat by hand. Server log streams below; open browser'); + console.log(' devtools for the browser console.'); + console.log(' Ctrl-C to stop.'); + console.log('────────────────────────────────────────────────────────────────────'); + console.log(''); + + // Stream the Director's growing log to this terminal. + let printed = 0; + const pump = setInterval(() => { + const all = director.readLogs(); + if (all.length > printed) { + process.stdout.write(all.slice(printed)); + printed = all.length; + } + }, 200); + + const shutdown = async (): Promise => { + clearInterval(pump); + await director.stop(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 8086cbc9c3f4e9f00834b20835124cd0d47b4aeb Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:25:58 +0000 Subject: [PATCH 027/362] =?UTF-8?q?Add=20strict=20client=E2=86=94server=20?= =?UTF-8?q?contract=20suite=20(frontend/contract)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real-wire guard for the v0.4 bug class: every one of those five bugs lived at a mocked boundary, so a unit test that mocked the seam stayed green while the wire disagreed. This suite mocks nothing — it boots the real built `gridfpv` Director (via the shared test-harness) and drives it with raw fetch/WebSocket AND the real `@gridfpv/protocol-client` dist, asserting the actual wire behaviour at every seam, and proving the real client's converged state where integration matters. Seams covered (frontend/contract/, run with `npm run contract`, Node env, no Docker): - snapshot.contract.ts (1,4): path-scoped snapshot routes return the right ProjectionBody variant + a numeric cursor; a wrong `?scope=`-style route is the SPA fallback, never a silent Snapshot (guards the path-vs-scope bug); cursor / last_lap_micros / duration_micros are JSON numbers (guards the i64/bigint class). - stream.contract.ts (2,3,7): /stream frames are externally-tagged StreamMessage `{ Change }` / `{ ReSnapshotRequired }` (guards the StreamMessage-unwrap bug); the per-stream sequence axis (starts at 1) is distinct from the snapshot cursor axis and the real client converges rather than dropping early frames as duplicates (guards the cursor/sequence freeze); an out-of-band contract_version gets VersionMismatch. - control.contract.ts (5,6): each Command acks {ok:true} and appends; a missing Content-Type is rejected (415); an illegal transition is {ok:false, error:BadRequest} at HTTP 200; the resulting change reaches a /stream subscriber; control requires an RD bearer token (401 without / with an unknown token), reads stay open. - race.contract.ts (8): a full sim race driven through the real control path, asserting the real protocol-client's exposed converged state (current_heat, climbing per-pilot laps, the scored result). Recorded gap (not a fix here): the server exposes no wire endpoint to mint a read-only join token, so seam 6's "read-only join-token rejected on control" can't be exercised over the wire; it stays covered by the Rust unit test, and the suite asserts the reachable equivalent (an unknown/revoked token is 401). See control.contract.ts. CI-able with just the built Director binary + Node 22 (no Docker, no browser); not yet wired into `cargo xtask ci` (Rust-only) — a separate step. Confined to frontend/: new contract/ dir, its vitest.config.ts, and a root `contract` npm script (+ vitest as a direct devDependency). No server/client/crate changes. Part of #13 (v0.4 contract suite). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/contract/README.md | 47 ++++++ frontend/contract/control.contract.ts | 209 +++++++++++++++++++++++++ frontend/contract/harness.ts | 177 +++++++++++++++++++++ frontend/contract/race.contract.ts | 128 +++++++++++++++ frontend/contract/snapshot.contract.ts | 183 ++++++++++++++++++++++ frontend/contract/stream.contract.ts | 176 +++++++++++++++++++++ frontend/contract/vitest.config.ts | 37 +++++ frontend/package-lock.json | 3 +- frontend/package.json | 6 +- 9 files changed, 963 insertions(+), 3 deletions(-) create mode 100644 frontend/contract/README.md create mode 100644 frontend/contract/control.contract.ts create mode 100644 frontend/contract/harness.ts create mode 100644 frontend/contract/race.contract.ts create mode 100644 frontend/contract/snapshot.contract.ts create mode 100644 frontend/contract/stream.contract.ts create mode 100644 frontend/contract/vitest.config.ts diff --git a/frontend/contract/README.md b/frontend/contract/README.md new file mode 100644 index 0000000..44a16d7 --- /dev/null +++ b/frontend/contract/README.md @@ -0,0 +1,47 @@ +# Client↔server contract suite + +The **strict wire contract** between the `@gridfpv/protocol-client` and the real Director. +It mocks **nothing**: every test boots the real built `gridfpv` Director (via +`../test-harness/director.ts`) and drives it with raw `fetch`/`WebSocket` **and** the real +protocol-client `dist`, asserting the actual wire behaviour at every seam. + +This is the durable guard for the bug class that hit us in v0.4 — all five of those bugs +lived at a _mocked_ boundary, so a unit test that mocked the seam stayed green while the real +wire disagreed. Here the boundary is real, so a contract regression fails a test. + +## Run it + +```sh +cd frontend +npm run build # so the protocol-client dist + Director binary are current +npm run contract # vitest run --config contract/vitest.config.ts +``` + +The Director binary is built on demand by the harness (`cargo build -p gridfpv-app`) if +missing, so a fresh checkout just works. + +## Layout (one file per seam group) + +| file | seams | asserts / guards | +| ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `snapshot.contract.ts` | 1, 4 | path-scoped snapshot routes + the right `ProjectionBody` variant + numeric `cursor`; a wrong route is the SPA fallback, never a silent `Snapshot` (guards the path-vs-`?scope=` bug); every integer field is a JSON `number` (guards the i64/bigint class) | +| `stream.contract.ts` | 2, 3, 7 | `/stream` frames are externally-tagged `StreamMessage` `{ Change }` / `{ ReSnapshotRequired }` (guards the StreamMessage-unwrap bug); the per-stream `sequence` axis (starts at 1) is distinct from the snapshot `cursor` axis and the real client converges rather than dropping early frames as duplicates (guards the cursor/sequence freeze); an out-of-band `contract_version` gets `VersionMismatch` | +| `control.contract.ts` | 5, 6 | each `Command` acks `{ ok: true }` and appends; missing `Content-Type` is rejected; an illegal transition is `{ ok: false, error: BadRequest }` (HTTP 200, not an HTTP error); the resulting change reaches a `/stream` subscriber; control needs an RD bearer token (401 without), reads are open | +| `race.contract.ts` | 8 | a full sim race driven through the real control path, asserting the real protocol-client's _exposed converged state_ (current heat, climbing per-pilot laps, the scored result) — the end-to-end client↔server contract in one test | +| `harness.ts` | — | shared helpers (control POST, raw sockets, wait predicates, a deterministic no-SPA assets dir) | + +## CI-ability + +The suite needs **only** the built Director binary + Node 22 — **no Docker, no browser**. It +is therefore CI-able as-is (build `gridfpv-app`, `npm ci && npm run build`, `npm run contract`) +and could later join a CI runner. It is **not** yet wired into `cargo xtask ci` (which is +Rust-only) — adding it is a separate, deliberate step. + +## Recorded gap + +The server has **no wire endpoint to mint a read-only join token** (`issue_join_token` exists +in `crates/server/src/auth.rs` but is unreachable over HTTP; only `GRIDFPV_RD_TOKEN` is +pinned). So the "a read-only join-token is rejected on control" case is asserted at the unit +level (`auth::tests::join_token_is_read_only_and_rejected_on_control`); over the wire we assert +the reachable equivalent — an unknown/revoked token is `401`. See the note in +`control.contract.ts`. diff --git a/frontend/contract/control.contract.ts b/frontend/contract/control.contract.ts new file mode 100644 index 0000000..e31cae3 --- /dev/null +++ b/frontend/contract/control.contract.ts @@ -0,0 +1,209 @@ +/** + * Seam 5 + seam 6: the control write path (headers + command shape + the resulting change + * reaching a `/stream` subscriber) and auth. + * + * guards: + * - seam 5 → command shape + headers: each `Command` variant acks `{ ok: true }` and appends; + * a missing `Content-Type: application/json` is rejected; an illegal transition is a + * well-formed `{ ok: false, error: ProtocolError(BadRequest) }` (NOT an HTTP error); and the + * consequence of a command reaches a `/stream` subscriber on the read path. + * - seam 6 → auth: control with no token / an unknown (revoked-equivalent) token is `401`; a + * valid RD token is accepted; reads (`/snapshot`, `/stream`) are open with no token. + * + * NOTE (recorded gap, not a failure): the server exposes **no wire endpoint to mint a + * read-only join token** — `issue_join_token` exists in `crates/server/src/auth.rs` but is not + * reachable over HTTP, and only the single `GRIDFPV_RD_TOKEN` is pinned. So the "a read-only + * join-token is rejected on control" arm of seam 6 cannot be exercised over the wire here; it + * is covered by the Rust unit test `auth::tests::join_token_is_read_only_and_rejected_on_control`. + * We assert the reachable equivalent: an unknown token (which a revoked token becomes) is 401. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Command } from '@gridfpv/types'; + +import { type Director } from '../test-harness/director.ts'; +import { + openSocket, + postControl, + rdControl, + startContractDirector, + tryOpenControlWs, + waitForFrame, + wsBase +} from './harness.ts'; + +const TOKEN = 'rd-control-contract'; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector({ token: TOKEN, simLaps: 1, simLapMs: 40 }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('seam 5: control command shape + headers', () => { + it('ScheduleHeat → CommandAck{ok:true} and the heat becomes snapshot-able', async () => { + const ack = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'h-shape', lineup: ['A', 'B'] } + }); + expect(ack).toEqual({ ok: true }); + const res = await fetch(`${director.baseUrl}/snapshot/heat/h-shape`); + expect(res.status).toBe(200); // it now resolves — the append took effect + }); + + it('the heat-loop transitions each ack ok and append in order', async () => { + await rdControl(director.baseUrl, TOKEN, { ScheduleHeat: { heat: 'h-loop', lineup: ['A'] } }); + const loop: Command[] = [ + { Stage: { heat: 'h-loop' } }, + { Arm: { heat: 'h-loop' } }, + { Start: { heat: 'h-loop' } }, + { Finish: { heat: 'h-loop' } }, + { Score: { heat: 'h-loop' } } + ]; + for (const command of loop) { + const ack = await rdControl(director.baseUrl, TOKEN, command); + expect(ack.ok, `command ${JSON.stringify(command)} should ack ok`).toBe(true); + } + }); + + it('Register + the marshaling adjudications ack ok', async () => { + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'h-marshal', lineup: ['A'] } + }); + const register = await rdControl(director.baseUrl, TOKEN, { + Register: { adapter: 'sim', competitor: 'A', pilot: 'acroace' } + }); + expect(register.ok).toBe(true); + const penalty = await rdControl(director.baseUrl, TOKEN, { + ApplyPenalty: { + heat: 'h-marshal', + competitor: 'A', + penalty: { TimeAdded: { micros: 2_000_000 } } + } + }); + expect(penalty.ok).toBe(true); + const voidHeat = await rdControl(director.baseUrl, TOKEN, { VoidHeat: { heat: 'h-marshal' } }); + expect(voidHeat.ok).toBe(true); + }); + + it('a missing Content-Type is rejected (the Json extractor refuses it)', async () => { + const { status } = await postControl( + director.baseUrl, + { ScheduleHeat: { heat: 'h-noct', lineup: [] } }, + { token: TOKEN, contentType: false } + ); + // axum's `Json` extractor requires `application/json`; without it the request is rejected + // (HTTP 4xx — 415 Unsupported Media Type in practice), NOT silently accepted. + expect(status).toBeGreaterThanOrEqual(400); + expect(status).toBeLessThan(500); + }); + + it('an illegal transition → CommandAck{ok:false, error: ProtocolError(BadRequest)}, HTTP 200', async () => { + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'h-illegal', lineup: ['A'] } + }); + // Start before Arm is illegal in the heat FSM. + const { status, body } = await postControl( + director.baseUrl, + { Start: { heat: 'h-illegal' } }, + { token: TOKEN } + ); + expect(status).toBe(200); // the failure rides in the ack body, not the HTTP status + const ack = body as { ok: boolean; error?: { code: string } }; + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('BadRequest'); + }); + + it('a command on an unknown heat → CommandAck{ok:false, error: UnknownScope}', async () => { + const { body } = await postControl( + director.baseUrl, + { Stage: { heat: 'never-scheduled' } }, + { token: TOKEN } + ); + const ack = body as { ok: boolean; error?: { code: string } }; + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('UnknownScope'); + }); + + it("a command's resulting change reaches a /stream subscriber on the read path", async () => { + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'h-reaches', lineup: ['A'] } + }); + const snap = (await (await fetch(`${director.baseUrl}/snapshot/heat/h-reaches`)).json()) as { + cursor: number; + }; + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Heat: { heat: 'h-reaches' } }, from: snap.cursor })); + // Drive a change over the CONTROL path; it must surface on the READ stream. + await rdControl(director.baseUrl, TOKEN, { Stage: { heat: 'h-reaches' } }); + await waitForFrame(frames, (f) => + f.some((x) => (x as { Change?: unknown }).Change !== undefined) + ); + const env = ( + frames.find((x) => (x as { Change?: unknown }).Change) as { + Change: { change: { FreshValue: { LiveRaceState: { phase: string } } } }; + } + ).Change; + expect(env.change.FreshValue.LiveRaceState.phase).toBe('Staged'); + ws.close(); + }); + + it('the bidirectional control WS (with the auth header) acks commands', async () => { + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/control`, { + Authorization: `Bearer ${TOKEN}` + }); + ws.send(JSON.stringify({ ScheduleHeat: { heat: 'h-ws', lineup: ['A'] } })); + await waitForFrame(frames, (f) => f.length > 0); + expect(frames[0]).toEqual({ ok: true }); + ws.close(); + }); +}); + +describe('seam 6: auth gates control, reads stay open', () => { + it('control with NO token → 401 ProtocolError(Unauthorized)', async () => { + const { status, body } = await postControl(director.baseUrl, { + ScheduleHeat: { heat: 'h-noauth', lineup: [] } + }); + expect(status).toBe(401); + expect((body as { code?: string }).code).toBe('Unauthorized'); + }); + + it('control with an UNKNOWN/revoked token → 401', async () => { + const { status } = await postControl( + director.baseUrl, + { ScheduleHeat: { heat: 'h-badtok', lineup: [] } }, + { token: 'not-a-real-token' } + ); + expect(status).toBe(401); + }); + + it('control with the valid RD token → accepted', async () => { + const ack = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'h-goodtok', lineup: [] } + }); + expect(ack.ok).toBe(true); + }); + + it('the control WS upgrade is rejected without the auth header', async () => { + const withAuth = await tryOpenControlWs(`${wsBase(director.baseUrl)}/control`, { + Authorization: `Bearer ${TOKEN}` + }); + const withoutAuth = await tryOpenControlWs(`${wsBase(director.baseUrl)}/control`); + expect(withAuth).toBe(true); + expect(withoutAuth).toBe(false); + }); + + it('reads are OPEN — /snapshot and /stream need no token', async () => { + const snap = await fetch(`${director.baseUrl}/snapshot/event/any`); + expect(snap.status).toBe(200); // no Authorization header sent + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Event: { event: 'any' } }, from: 0 })); + // It subscribes without auth and does not get an Unauthorized error frame. + await new Promise((r) => setTimeout(r, 300)); + expect((frames[0] as { code?: string } | undefined)?.code).not.toBe('Unauthorized'); + ws.close(); + }); +}); diff --git a/frontend/contract/harness.ts b/frontend/contract/harness.ts new file mode 100644 index 0000000..529be52 --- /dev/null +++ b/frontend/contract/harness.ts @@ -0,0 +1,177 @@ +/** + * Shared helpers for the client↔server contract suite (#13). + * + * Everything here drives the **real** Director over the real wire — no mocks. The one + * deliberate bit of configuration is {@link emptyAssetsDir}: the Director serves the RD + * console SPA as a `fallback_service`, so an unmatched route normally returns `index.html` + * (HTTP 200) when the console is built. Pointing `GRIDFPV_ASSETS` at an empty dir makes the + * fallback deterministic — a `404` with a short text body — *and faithful to the contract*: + * either way an unknown route is NOT a `Snapshot`, which is exactly what seam 1 asserts. + */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + type Director, + type StartDirectorOptions, + startDirector +} from '../test-harness/director.ts'; + +import type { Command } from '@gridfpv/types'; + +/** A fresh empty dir to point `GRIDFPV_ASSETS` at, so the SPA fallback is a deterministic 404. */ +export function emptyAssetsDir(): string { + return mkdtempSync(join(tmpdir(), 'gridfpv-no-assets-')); +} + +/** Boot a Director with a deterministic (no-SPA) assets dir layered on. */ +export function startContractDirector(opts: StartDirectorOptions = {}): Promise { + return startDirector({ + ...opts, + env: { GRIDFPV_ASSETS: emptyAssetsDir(), ...(opts.env ?? {}) } + }); +} + +/** The raw HTTP result of a `POST /control` — the status and the parsed `CommandAck`-ish body. */ +export interface ControlResult { + status: number; + /** The parsed JSON body (a `CommandAck` on the happy path), or `undefined` if not JSON. */ + body: unknown; +} + +/** Options for {@link postControl} — override the headers to exercise the auth/Content-Type contract. */ +export interface PostControlOptions { + /** The bearer token to send (omit for no `Authorization` header). */ + token?: string; + /** Send `Content-Type: application/json` (default `true`; set `false` to test the rejection). */ + contentType?: boolean; +} + +/** `POST /control` a single command with explicit header control, returning the raw status + body. */ +export async function postControl( + baseUrl: string, + command: Command, + opts: PostControlOptions = {} +): Promise { + const { token, contentType = true } = opts; + const headers: Record = {}; + if (contentType) headers['Content-Type'] = 'application/json'; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${baseUrl}/control`, { + method: 'POST', + headers, + body: JSON.stringify(command) + }); + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + return { status: res.status, body }; +} + +/** A `CommandAck` as it actually arrives on the wire. */ +export interface WireCommandAck { + ok: boolean; + error?: { code: string; message: string }; +} + +/** `POST /control` with a valid RD token + JSON content-type, asserting nothing — returns the ack. */ +export async function rdControl( + baseUrl: string, + token: string, + command: Command +): Promise { + const { body } = await postControl(baseUrl, command, { token }); + return body as WireCommandAck; +} + +/** The `ws(s)://…` base for a Director's `http(s)://…` base URL. */ +export function wsBase(baseUrl: string): string { + return baseUrl.replace(/^http/, 'ws'); +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Open a raw `WebSocket`, collecting every parsed frame and the close event. Resolves once + * the socket is open (or rejects if it errors/closes before opening). `headers` are passed + * to undici's `WebSocket` (Node) — the control WS reads its `Authorization` there. + */ +export async function openSocket( + url: string, + headers?: Record +): Promise<{ + ws: WebSocket; + frames: unknown[]; + closed: Promise<{ code: number; reason: string }>; +}> { + const ws = headers ? new WebSocket(url, { headers } as unknown as string[]) : new WebSocket(url); + const frames: unknown[] = []; + let resolveClosed!: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + resolveClosed = res; + }); + ws.onmessage = (ev: MessageEvent): void => { + frames.push(JSON.parse(ev.data as string)); + }; + ws.onclose = (ev: CloseEvent): void => resolveClosed({ code: ev.code, reason: ev.reason }); + await new Promise((res, rej) => { + ws.onopen = (): void => res(); + ws.onerror = (): void => rej(new Error(`websocket failed to open: ${url}`)); + }); + return { ws, frames, closed }; +} + +/** + * Try to open a control WebSocket and report whether the upgrade succeeded. The control + * upgrade is auth-gated, so a missing/invalid token closes the socket instead of opening it. + */ +export async function tryOpenControlWs( + url: string, + headers?: Record +): Promise { + const ws = headers ? new WebSocket(url, { headers } as unknown as string[]) : new WebSocket(url); + const opened = await new Promise((res) => { + ws.onopen = (): void => res(true); + ws.onerror = (): void => res(false); + ws.onclose = (): void => res(false); + }); + try { + ws.close(); + } catch { + /* ignore */ + } + return opened; +} + +/** Poll `cond` against the latest stream frames until it holds or the deadline passes. */ +export async function waitForFrame( + frames: unknown[], + cond: (frames: unknown[]) => boolean, + timeoutMs = 5_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond(frames)) return; + await sleep(20); + } + throw new Error(`condition not met within ${timeoutMs}ms; frames=${JSON.stringify(frames)}`); +} + +/** Drive a heat through its legal loop to `Running` over the control path (schedule already done). */ +export async function driveToRunning(baseUrl: string, token: string, heat: string): Promise { + for (const command of [ + { Stage: { heat } }, + { Arm: { heat } }, + { Start: { heat } } + ] as Command[]) { + const ack = await rdControl(baseUrl, token, command); + if (!ack.ok) + throw new Error( + `drive to running failed at ${JSON.stringify(command)}: ${JSON.stringify(ack)}` + ); + } +} diff --git a/frontend/contract/race.contract.ts b/frontend/contract/race.contract.ts new file mode 100644 index 0000000..6baf2f3 --- /dev/null +++ b/frontend/contract/race.contract.ts @@ -0,0 +1,128 @@ +/** + * Seam 8: the full sim race — the end-to-end client↔server contract in one test. + * + * guards: the whole integration (every v0.4 bug lived at a mocked boundary). This drives a + * real race through the real control path (register → schedule → stage/arm/start → finish → + * score) while the **real `@gridfpv/protocol-client`** holds a `/stream` subscription, and + * asserts the client's *exposed converged state* tracks the race: current heat appears, + * per-pilot laps climb, the phase walks Scheduled → Running → Finished → Scored. If any seam + * (StreamMessage unwrap, cursor/sequence axis, bigint numbers, control headers) were wrong, + * the client would not converge. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { connect } from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { driveToRunning, rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-race-contract'; +const HEAT = 'final'; +const LINEUP = ['A', 'B']; + +let director: Director; + +beforeAll(async () => { + // A brisk sim (small lap time) so the race completes well inside the test timeout. + director = await startContractDirector({ token: TOKEN, simLaps: 3, simLapMs: 40 }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +interface LiveBody { + LiveRaceState?: { + current_heat?: string; + phase: string; + progress?: Array<{ competitor: string; laps_completed: number; last_lap_micros?: number }>; + }; +} + +describe('seam 8: a full race converges through the real protocol-client', () => { + it('current_heat appears, laps climb, and the result is reached', async () => { + // Bind pilots, then schedule the heat (over the real control path). + await rdControl(director.baseUrl, TOKEN, { + Register: { adapter: 'sim', competitor: 'A', pilot: 'ace' } + }); + await rdControl(director.baseUrl, TOKEN, { + Register: { adapter: 'sim', competitor: 'B', pilot: 'bee' } + }); + await rdControl(director.baseUrl, TOKEN, { ScheduleHeat: { heat: HEAT, lineup: LINEUP } }); + + // The real client subscribes to the heat scope. + const client = connect({ baseUrl: director.baseUrl, scope: { Heat: { heat: HEAT } } }); + try { + await waitForState(client, (s) => s.body !== undefined); + // current_heat is set once the heat is scheduled and folded. + const initial = (client.getState().body as LiveBody).LiveRaceState; + expect(initial?.current_heat).toBe(HEAT); + + // Drive the heat to Running; the sim emits laps in real time. + await driveToRunning(director.baseUrl, TOKEN, HEAT); + + // The client's exposed progress must climb to ≥ 2 laps for at least one pilot. + await waitForState( + client, + (s) => { + const ls = (s.body as LiveBody).LiveRaceState; + return ls?.phase === 'Running' && (ls.progress ?? []).some((p) => p.laps_completed >= 2); + }, + 12_000 + ); + const running = (client.getState().body as LiveBody).LiveRaceState!; + expect(running.phase).toBe('Running'); + const laps = (running.progress ?? []).map((p) => p.laps_completed); + expect(Math.max(...laps)).toBeGreaterThanOrEqual(2); + // The last-lap timing is a plain number wherever a lap has been banked. + for (const p of running.progress ?? []) { + if (p.laps_completed >= 1) expect(typeof p.last_lap_micros).toBe('number'); + } + + // Finish + Score; the client converges to the scored phase. + await rdControl(director.baseUrl, TOKEN, { Finish: { heat: HEAT } }); + await rdControl(director.baseUrl, TOKEN, { Score: { heat: HEAT } }); + await waitForState( + client, + (s) => (s.body as LiveBody).LiveRaceState?.phase === 'Scored', + 6_000 + ); + expect((client.getState().body as LiveBody).LiveRaceState?.phase).toBe('Scored'); + + // And the scored HeatResult is served on the read path (a result exists). + const result = (await ( + await fetch(`${director.baseUrl}/snapshot/heat/${HEAT}?projection=result`) + ).json()) as { body: { HeatResult: { places: unknown[] } } }; + expect(result.body.HeatResult.places.length).toBe(LINEUP.length); + } finally { + client.close(); + } + }); +}); + +/** Resolve once the client's exposed state satisfies `pred` (or reject on timeout). */ +function waitForState( + client: ReturnType, + pred: (state: ReturnType) => boolean, + timeoutMs = 5_000 +): Promise { + return new Promise((res, rej) => { + let unsub: () => void = () => {}; + const to = setTimeout(() => { + unsub(); + rej( + new Error( + `client state never satisfied predicate within ${timeoutMs}ms; last=${JSON.stringify( + client.getState().body + )}` + ) + ); + }, timeoutMs); + unsub = client.onState((s) => { + if (pred(s)) { + clearTimeout(to); + unsub(); + res(); + } + }); + }); +} diff --git a/frontend/contract/snapshot.contract.ts b/frontend/contract/snapshot.contract.ts new file mode 100644 index 0000000..52aeadc --- /dev/null +++ b/frontend/contract/snapshot.contract.ts @@ -0,0 +1,183 @@ +/** + * Seam 1 + seam 4: snapshot routes are **path-scoped**, and the numbers on the wire are + * JSON `number`s (never bigint/string). + * + * guards: the path-vs-`?scope=` bug (a client that addressed `/snapshot?scope=…` got the SPA + * shell / a generic 404 — never a `Snapshot` — silently; v0.4 bug #1) and the i64/bigint + * class (a `cursor`/`last_lap_micros`/`duration_micros` that arrived as a bigint or string + * broke comparison + rendering; v0.4 bug class). + * + * Asserts the real wire: each correct path returns `200` + the right externally-tagged + * `ProjectionBody` variant + a numeric `cursor`; a wrong/unknown route does NOT return a + * parseable `Snapshot` (it is the SPA fallback, not a silent 200 Snapshot); and every + * integer field is `typeof === 'number'`. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type Director } from '../test-harness/director.ts'; +import { rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-snapshot-contract'; +const HEAT = 'q-1'; +const LINEUP = ['A', 'B']; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector({ token: TOKEN, simLaps: 2, simLapMs: 40 }); + // A non-empty log so the snapshot cursor is > 0 and the scopes resolve (a heat must be + // scheduled before /snapshot/heat/{id} returns anything but UnknownScope). + const ack = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: HEAT, lineup: LINEUP } + }); + expect(ack.ok).toBe(true); +}); + +afterAll(async () => { + await director?.stop(); +}); + +/** Fetch a snapshot route and return the raw status + parsed JSON (or `undefined`). */ +async function getSnapshot(path: string): Promise<{ status: number; json: unknown; text: string }> { + const res = await fetch(`${director.baseUrl}${path}`); + const text = await res.text(); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + json = undefined; + } + return { status: res.status, json, text }; +} + +/** Whether a parsed body is a wire `Snapshot` (`{ cursor: number, body: ProjectionBody }`). */ +function isSnapshot(v: unknown): v is { cursor: number; body: Record } { + return ( + typeof v === 'object' && + v !== null && + 'cursor' in v && + 'body' in v && + typeof (v as { body: unknown }).body === 'object' + ); +} + +describe('seam 1: snapshot routes are path-scoped', () => { + it('GET /snapshot/event/{id} → 200 LiveRaceState + numeric cursor', async () => { + const { status, json } = await getSnapshot('/snapshot/event/spring-cup'); + expect(status).toBe(200); + expect(isSnapshot(json)).toBe(true); + const snap = json as { cursor: number; body: Record }; + expect(Object.keys(snap.body)).toEqual(['LiveRaceState']); + expect(typeof snap.cursor).toBe('number'); + }); + + it('GET /snapshot/heat/{id} → 200 LiveRaceState (default projection)', async () => { + const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}`); + expect(status).toBe(200); + expect(Object.keys((json as { body: object }).body)).toEqual(['LiveRaceState']); + }); + + it('GET /snapshot/heat/{id}?projection=laps → 200 LapList', async () => { + const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=laps`); + expect(status).toBe(200); + expect(Object.keys((json as { body: object }).body)).toEqual(['LapList']); + }); + + it('GET /snapshot/heat/{id}?projection=result → 200 HeatResult', async () => { + const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=result`); + expect(status).toBe(200); + expect(Object.keys((json as { body: object }).body)).toEqual(['HeatResult']); + }); + + it('GET /snapshot/class/{event}/{class} → 200 LiveRaceState', async () => { + const { status, json } = await getSnapshot('/snapshot/class/spring-cup/open'); + expect(status).toBe(200); + expect(Object.keys((json as { body: object }).body)).toEqual(['LiveRaceState']); + }); + + it('GET /snapshot/pilot/{event}/{pilot} → 200 LapList for a competitor with laps', async () => { + // The sim emits passes for A/B once the heat runs; drive it so the pilot scope resolves. + await rdControl(director.baseUrl, TOKEN, { Stage: { heat: HEAT } }); + await rdControl(director.baseUrl, TOKEN, { Arm: { heat: HEAT } }); + await rdControl(director.baseUrl, TOKEN, { Start: { heat: HEAT } }); + // Poll the pilot snapshot until A has a lap (sim paces in real time). + const deadline = Date.now() + 8_000; + let snap: { status: number; json: unknown } | undefined; + while (Date.now() < deadline) { + snap = await getSnapshot('/snapshot/pilot/spring-cup/A'); + if (snap.status === 200) break; + await new Promise((r) => setTimeout(r, 50)); + } + expect(snap?.status).toBe(200); + expect(Object.keys((snap?.json as { body: object }).body)).toEqual(['LapList']); + }); + + it('an unknown SCOPE id → 404 ProtocolError(UnknownScope), not a silent Snapshot', async () => { + // A real path with a missing id: the contract is a typed 404, not a 200 fallback. + const { status, json } = await getSnapshot('/snapshot/heat/does-not-exist'); + expect(status).toBe(404); + expect((json as { code?: string }).code).toBe('UnknownScope'); + }); + + it('a WRONG route (`?scope=` style) is NOT a Snapshot — it is the SPA fallback', async () => { + // THE path-vs-scope bug: a client that addressed the snapshot by a `?scope=` query (or + // any path the protocol router does not own) falls through to the SPA `fallback_service`. + // With no built console that is a 404 text body; with one it is `index.html` (200). Either + // way it is NOT a parseable `Snapshot`, so a client must address by PATH, never `?scope=`. + const wrong = await getSnapshot('/snapshot?scope=event:spring-cup'); + expect(isSnapshot(wrong.json)).toBe(false); + expect(wrong.text).not.toContain('"cursor"'); + }); +}); + +describe('seam 4: wire integers are JSON numbers, never bigint/string', () => { + it('snapshot cursor is a number > 0 on a non-empty log', async () => { + const { json } = await getSnapshot('/snapshot/event/spring-cup'); + const snap = json as { cursor: number }; + expect(typeof snap.cursor).toBe('number'); + expect(Number.isInteger(snap.cursor)).toBe(true); + expect(snap.cursor).toBeGreaterThan(0); + }); + + it('PilotProgress.laps_completed + last_lap_micros are numbers', async () => { + // The heat is running from the test above; poll until a pilot has a completed lap, then + // assert the live-state integer fields are plain numbers (the i64/u32 → number contract). + const deadline = Date.now() + 8_000; + let progressed: { laps_completed: unknown; last_lap_micros?: unknown } | undefined; + while (Date.now() < deadline) { + const { json } = await getSnapshot(`/snapshot/heat/${HEAT}`); + const ls = ( + json as { body: { LiveRaceState: { progress?: Array> } } } + ).body.LiveRaceState; + const withLap = (ls.progress ?? []).find((p) => typeof p.last_lap_micros === 'number'); + if (withLap) { + progressed = withLap as { laps_completed: unknown; last_lap_micros?: unknown }; + break; + } + await new Promise((r) => setTimeout(r, 50)); + } + expect(progressed).toBeDefined(); + expect(typeof progressed?.laps_completed).toBe('number'); + expect(typeof progressed?.last_lap_micros).toBe('number'); + }); + + it('Lap.duration_micros (laps projection) is a number', async () => { + const deadline = Date.now() + 8_000; + let dur: unknown; + while (Date.now() < deadline) { + const { json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=laps`); + const list = ( + json as { + body: { LapList: { competitors: Array<{ laps: Array<{ duration_micros: unknown }> }> } }; + } + ).body.LapList; + const firstLap = list.competitors.flatMap((c) => c.laps)[0]; + if (firstLap) { + dur = firstLap.duration_micros; + break; + } + await new Promise((r) => setTimeout(r, 50)); + } + expect(typeof dur).toBe('number'); + }); +}); diff --git a/frontend/contract/stream.contract.ts b/frontend/contract/stream.contract.ts new file mode 100644 index 0000000..1d4356e --- /dev/null +++ b/frontend/contract/stream.contract.ts @@ -0,0 +1,176 @@ +/** + * Seam 2 + seam 3 + seam 7: the `/stream` WebSocket frames are externally-tagged + * `StreamMessage`s, the per-stream `sequence` axis is distinct from the snapshot `cursor` + * axis (and the real client applies early frames rather than dropping them as duplicates), + * and an out-of-band `contract_version` gets the `VersionMismatch` refresh signal. + * + * guards: + * - seam 2 → the StreamMessage-unwrap bug: a frame is `{ "Change": ChangeEnvelope }` / + * `{ "ReSnapshotRequired": … }`, NOT a bare envelope. A client that read `frame.sequence` + * instead of `frame.Change.sequence` saw `undefined` and stalled (v0.4 bug). + * - seam 3 → the cursor/sequence conflation (the freeze): a snapshot of a non-empty log has + * `cursor > 0`, while the stream `sequence` restarts at 1; conflating the two axes made the + * client treat the first envelopes as `<= cursor` "duplicates" and freeze. The real client + * must converge. + * - seam 7 → version negotiation: a subscribe carrying an unsupported `contract_version` is + * answered with a `VersionMismatch` error frame + close; an absent version streams normally. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { connect } from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { openSocket, rdControl, startContractDirector, waitForFrame, wsBase } from './harness.ts'; + +const TOKEN = 'rd-stream-contract'; +const HEAT = 'q-1'; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector({ token: TOKEN, simLaps: 2, simLapMs: 40 }); + const ack = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: HEAT, lineup: ['A', 'B'] } + }); + expect(ack.ok).toBe(true); +}); + +afterAll(async () => { + await director?.stop(); +}); + +/** The snapshot cursor for a scope path — the `from:` a stream resumes at. */ +async function snapshotCursor(path: string): Promise { + const res = await fetch(`${director.baseUrl}${path}`); + const snap = (await res.json()) as { cursor: number }; + return snap.cursor; +} + +describe('seam 2: stream frames are externally-tagged StreamMessage', () => { + it('a control append produces a `{ Change: ChangeEnvelope }` frame, not a bare envelope', async () => { + const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); + + // A heat-state change after the subscribe re-folds the scope and pushes one envelope. + await rdControl(director.baseUrl, TOKEN, { Stage: { heat: HEAT } }); + await waitForFrame(frames, (f) => f.length > 0); + + const frame = frames[0] as Record; + // The contract: a tagged StreamMessage. The envelope is UNDER `Change`, not at top level. + expect('Change' in frame).toBe(true); + expect('sequence' in frame).toBe(false); // would be true if the server sent a bare envelope + const env = frame.Change as Record; + expect(env).toHaveProperty('sequence'); + expect(env).toHaveProperty('projection'); + expect(env).toHaveProperty('change'); + ws.close(); + }); + + it('a stale resume cursor yields `{ ReSnapshotRequired: ProtocolError(StaleCursor) }`', async () => { + // Push the log tail far past the retained window (256), then resume from offset 1: that + // offset is below the window, so the server sends the terminal ReSnapshotRequired signal. + for (let i = 0; i < 300; i++) { + await rdControl(director.baseUrl, TOKEN, { + Register: { adapter: 'sim', competitor: `x${i}`, pilot: `p${i}` } + }); + } + const { ws, frames, closed } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: 1 })); + await waitForFrame(frames, (f) => f.length > 0); + + const frame = frames[0] as { ReSnapshotRequired?: { code: string } }; + expect(frame.ReSnapshotRequired).toBeDefined(); + expect(frame.ReSnapshotRequired?.code).toBe('StaleCursor'); + await closed; // the server closes after the signal + }); +}); + +describe('seam 3: sequence and cursor are distinct axes; the client converges', () => { + it('a non-empty-log snapshot has cursor > 0 while the stream sequence starts at 1', async () => { + const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); + expect(cursor).toBeGreaterThan(0); // the cursor axis is well past 1 + + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); + await rdControl(director.baseUrl, TOKEN, { Arm: { heat: HEAT } }); + await waitForFrame(frames, (f) => f.length > 0); + + const seq = (frames[0] as { Change: { sequence: number } }).Change.sequence; + // The sequence axis restarts at 1 for THIS subscription — independent of the cursor (>0). + expect(seq).toBe(1); + expect(seq).not.toBe(cursor); + ws.close(); + }); + + it('the real protocol-client applies early frames (does NOT drop them as duplicates) and converges', async () => { + // Subscribe with the real client to a non-empty log (cursor > 0). If it conflated cursor + // and sequence it would treat sequence 1,2,3 (<= cursor) as duplicates and freeze. It must + // instead converge to the running heat with climbing laps. + const client = connect({ baseUrl: director.baseUrl, scope: { Heat: { heat: HEAT } } }); + try { + await waitForState(client, (s) => s.body !== undefined); + await rdControl(director.baseUrl, TOKEN, { Start: { heat: HEAT } }); + await waitForState( + client, + (s) => { + const b = s.body as + | { LiveRaceState?: { phase: string; progress?: Array<{ laps_completed: number }> } } + | undefined; + const ls = b?.LiveRaceState; + return ls?.phase === 'Running' && (ls.progress ?? []).some((p) => p.laps_completed >= 1); + }, + 8_000 + ); + const ls = (client.getState().body as { LiveRaceState: { phase: string } }).LiveRaceState; + expect(ls.phase).toBe('Running'); // converged — not frozen on stale "duplicates" + } finally { + client.close(); + } + }); +}); + +describe('seam 7: contract-version negotiation', () => { + it('an out-of-band contract_version → VersionMismatch refresh signal + close', async () => { + const { ws, frames, closed } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, contract_version: 999 })); + await waitForFrame(frames, (f) => f.length > 0); + const frame = frames[0] as { code?: string }; + expect(frame.code).toBe('VersionMismatch'); + await closed; // the server closes after the refresh signal + }); + + it('an absent contract_version subscribes and streams normally', async () => { + const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); + const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + // No contract_version field at all — treated as this build's version, streams fine. + ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); + await rdControl(director.baseUrl, TOKEN, { Finish: { heat: HEAT } }); + await waitForFrame(frames, (f) => + f.some((x) => (x as { Change?: unknown }).Change !== undefined) + ); + expect((frames[0] as { code?: string }).code).not.toBe('VersionMismatch'); + ws.close(); + }); +}); + +/** Resolve once the client's exposed state satisfies `pred` (or reject on timeout). */ +function waitForState( + client: ReturnType, + pred: (state: ReturnType) => boolean, + timeoutMs = 5_000 +): Promise { + return new Promise((res, rej) => { + let unsub: () => void = () => {}; + const to = setTimeout(() => { + unsub(); + rej(new Error(`client state never satisfied predicate within ${timeoutMs}ms`)); + }, timeoutMs); + unsub = client.onState((s) => { + if (pred(s)) { + clearTimeout(to); + unsub(); + res(); + } + }); + }); +} diff --git a/frontend/contract/vitest.config.ts b/frontend/contract/vitest.config.ts new file mode 100644 index 0000000..8edba60 --- /dev/null +++ b/frontend/contract/vitest.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** + * The client↔server **contract suite** (#13, v0.4). + * + * This is the durable guard for the bug class that hit us in v0.4: every one of those + * five bugs lived at a *mocked* boundary, so a unit test that mocked the seam stayed green + * while the real wire disagreed. The suite here mocks **nothing** — it boots the real built + * `gridfpv` Director (via the shared `test-harness/director.ts`) and drives it with raw + * `fetch`/`WebSocket` *and* the real `@gridfpv/protocol-client` `dist`, asserting the actual + * wire behaviour at every seam. + * + * Node environment (no jsdom): Node 22 supplies the global `fetch` + `WebSocket` the client + * uses by default, so the same code path the browser runs is exercised here with no polyfill. + * + * Like the protocol-client's own vitest config, it mirrors the `@bindings` alias so the + * `@gridfpv/types` import chain resolves; only `export type`s are pulled in, so nothing of + * the bindings actually executes — this only satisfies resolution. + */ +export default defineConfig({ + resolve: { + alias: { + '@bindings': fileURLToPath(new URL('../../bindings', import.meta.url)) + } + }, + test: { + environment: 'node', + include: ['**/*.contract.ts'], + // Booting a real Director per file (build-on-demand on a cold checkout) is slow; give + // each test room and run files serially so several Directors don't fight for the build + // lock / ports on a cold cache. + testTimeout: 30_000, + hookTimeout: 60_000, + fileParallelism: false + } +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 521c443..a1f7da6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,7 +28,8 @@ "svelte-eslint-parser": "^0.43.0", "tsx": "^4.22.4", "typescript": "^5.7.2", - "typescript-eslint": "^8.18.1" + "typescript-eslint": "^8.18.1", + "vitest": "^2.1.8" } }, "apps/rd-console": { diff --git a/frontend/package.json b/frontend/package.json index c162efe..2f1e82e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "dev:rd-console": "npm run dev --workspace @gridfpv/rd-console", "e2e": "playwright test", "e2e:install": "playwright install chromium", - "observe": "tsx test-harness/observe.ts" + "observe": "tsx test-harness/observe.ts", + "contract": "vitest run --config contract/vitest.config.ts" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -36,6 +37,7 @@ "svelte-eslint-parser": "^0.43.0", "tsx": "^4.22.4", "typescript": "^5.7.2", - "typescript-eslint": "^8.18.1" + "typescript-eslint": "^8.18.1", + "vitest": "^2.1.8" } } From 85ddb4dddde660d4e3b827ab852bc31db39d7b90 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:39:47 +0000 Subject: [PATCH 028/362] Unify the two marshaling-fold implementations (#39) The void/insert/adjust marshaling fold was implemented twice and kept in agreement only by tests: `gridfpv-projection::lap_list_marshaled` built a corrected lap-gate view internally, while `gridfpv-engine::event::score_marshaled` re-implemented the identical DetectionVoided/LapInserted/LapAdjusted fold (last-writer-wins by offset, "void the void") to get absolute-timed passes for scoring. Make the fold live in one place: - Projection now exposes `pub fn corrected_passes((offset, &Event)) -> Vec` as the single source of truth for the fold. It returns the surviving lap-gate passes (synthetic inserts included, re-times applied) in offset order. - `lap_list_marshaled` becomes a thin consumer: it groups `corrected_passes`' output by competitor, orders each group, and derives laps as before. - `score_marshaled` calls `gridfpv_projection::corrected_passes` (tagging the log with positional append offsets) and scores its output, deleting the engine's duplicated fold (the local `Entry` enum / resolution loop are gone). Public behavior is byte-for-byte unchanged: the projection marshaling goldens, the engine `full_event` deterministic-replay tests, and the `score`/`scoring` tests all pass with identical expected values, since both paths now fold through the same function. The duplication is noted in comments on both sides. Confined to crates/projection + crates/engine; wire types/bindings, other crates, and behavior are untouched. `cargo xtask ci` green; `cargo build -p gridfpv-engine --features live` and `--features live` clippy clean. Part of #39. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/event.rs | 170 +++++------------------------------ crates/projection/src/lib.rs | 144 ++++++++++++++++------------- 2 files changed, 106 insertions(+), 208 deletions(-) diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 0139f64..8340534 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -35,15 +35,14 @@ //! 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; @@ -157,12 +156,17 @@ 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). /// /// `race_start` is the shared race clock for [`WinCondition::Timed`] (ignored by the /// qualifying / first-to-N conditions), matching [`score`]. @@ -171,149 +175,23 @@ pub fn score_marshaled( 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. + let corrected = + gridfpv_projection::corrected_passes(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + score(&corrected, condition, race_start) } #[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"; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 02e61df..46d7772 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -17,11 +17,15 @@ //! 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)] @@ -189,27 +193,21 @@ 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` of the surviving lap-gate passes (synthetic inserts included, re-timed +/// passes moved to their new instant), in **offset order** — the raw [`Pass`]es in the +/// input are never mutated (architecture.html §3); callers re-group/re-order as needed. /// /// # Adjudications folded /// @@ -251,10 +249,10 @@ 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 +/// 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 where I: IntoIterator, { @@ -262,15 +260,11 @@ 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 void ruling: the target entry is dropped from the corrected view. @@ -290,13 +284,15 @@ where } => { 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, + }), ); } Event::LapAdjusted { target, at } => { @@ -325,9 +321,9 @@ 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() { + for entry in entries.values() { match entry { - Entry::RawPass(_) | Entry::Inserted { .. } => {} + Entry::RawPass(_) | Entry::Inserted(_) => {} 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. @@ -363,33 +359,57 @@ where } } - // 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 surviving passes (raw + inserted) with any re-time applied, in offset + // order; callers re-group and re-order them as needed. + let mut out: Vec = Vec::new(); for (offset, entry) in entries.iter() { if voided.get(offset).copied().unwrap_or(false) { continue; } - 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, - }; + 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 +} + +/// 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, +{ + // 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. + let mut by_competitor: BTreeMap> = BTreeMap::new(); + for pass in corrected_passes(events) { by_competitor - .entry(corrected.competitor.clone()) + .entry(CompetitorKey::from_pass(&pass)) .or_default() - .push(corrected); + .push(pass); } let competitors = by_competitor @@ -416,13 +436,13 @@ 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 { +fn laps_from_corrected(passes: &[Pass]) -> Vec { passes .windows(2) .enumerate() From 5cfb8ae060c82d0c5cc8f572cd6810cbb3c32a4e Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:40:53 +0000 Subject: [PATCH 029/362] rd-console: tick the race clock from the live phase (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RaceClock in the live-control screen always read 0:00.000 because nothing drove its elapsedMs — LiveRaceState carries no race-start time. Drive a client-side elapsed clock from the heat phase in LiveRaceControl.svelte (a $effect keyed on `phase`): - Running → capture a start timestamp and tick elapsedMs every 50ms via setInterval; the effect teardown clears the interval (no leaks, and no restart on same-phase re-renders). - Finished / Scored → freeze the clock at its last ticked value, stop the interval. - Scheduled / Staged / Armed / no heat → reset to 0. The off-ramps Abort/Restart/Discard fold back onto one of these phases, so they reset by the same rule. A late join (heat already Running at mount) starts counting from "now", so the displayed time is approximate — acceptable for v1. A comment notes the fuller fix: a server-authoritative start time in LiveRaceState (the recorded-at of the Running transition), tracked as a #62 follow-up. Unit-tested with vitest fake timers: Running starts it, Finished/Scored freeze it, Scheduled/no-heat reset it, and a repeated Running push does not restart it. The browser e2e (which asserts laps climbing, not the clock value) stays green and needed no assertion change. Part of #62. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../src/screens/LiveRaceControl.svelte | 40 +++++- .../rd-console/tests/LiveRaceControl.test.ts | 118 +++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index c8bdace..422c202 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -32,6 +32,44 @@ const heat = $derived(live?.current_heat); const primary = $derived(primaryAction(phase)); + // ── Race clock (#62) ──────────────────────────────────────────────────────────────── + // Drive the `RaceClock`'s `elapsedMs` client-side off the live `phase`. Behaviour: + // • Running → tick a wall-clock timer (`Date.now() - start`) every 50ms. + // • Finished / Scored → freeze the clock at its last ticked value (stop ticking). + // • Scheduled/Staged/Armed → reset to 0 (a fresh/idle heat, or no heat at all). + // The off-ramps Abort/Restart/Discard aren't phases of their own — they fold back onto + // one of the above (typically Scheduled), so they fall out of these same rules: an abort + // back to Scheduled resets, a restart back to Scheduled resets, etc. + // + // This is the v1 fix and is *approximate*: on a late join (the heat is already Running + // when this screen mounts, or after a reconnect) we start counting from "now", not from + // the real heat start, so the displayed time can be short. The fuller fix (#62 follow-up) + // is a server-authoritative start time in `LiveRaceState` — the recorded-at of the + // Running transition — which makes the clock exact and reconnect-safe. + let elapsedMs = $state(0); + + $effect(() => { + // `phase` is the only reactive read in this effect, so it re-runs *only* on a phase + // change — not on every render — which keeps the clock from restarting spuriously. + if (phase === 'Running') { + const startedAt = Date.now(); + const advance = () => { + elapsedMs = Date.now() - startedAt; + }; + advance(); + const id = setInterval(advance, 50); + // Teardown: stop ticking when the phase leaves Running (or on unmount). The next + // effect run applies the freeze (Finished/Scored) or reset (everything else). + return () => clearInterval(id); + } + if (phase === 'Finished' || phase === 'Scored') { + // Freeze: leave `elapsedMs` at its last Running value. + return; + } + // Scheduled / Staged / Armed / no heat (incl. where Abort/Restart/Discard land): reset. + elapsedMs = 0; + }); + // A live, provisional leaderboard from the running order + per-pilot progress, so the // RD sees standings before the heat is scored. Built into a `HeatResult` so we reuse // the shared `Leaderboard` component (laps + last-lap-time as the metric). @@ -81,7 +119,7 @@

    {phase}
    - +
    {#if live?.on_deck}
    diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 7cb14a8..4365503 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { fireEvent } from '@testing-library/dom'; +import { tick } from 'svelte'; +import type { LiveRaceState } from '@gridfpv/types'; import LiveRaceControl from '../src/screens/LiveRaceControl.svelte'; import { makeTestSession } from './support.js'; import { liveRunning, failAck } from './fixtures.js'; @@ -58,4 +60,118 @@ describe('LiveRaceControl', () => { // Heat sheet + live standing both list the lineup. expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); }); + + describe('race clock (#62)', () => { + // The pure `RaceClock` renders `M:SS.mmm` into a `role="timer"`; we read that text to + // assert the client-side ticking driven from the live `phase` in LiveRaceControl. + const clockText = () => screen.getByRole('timer').textContent?.trim(); + + // A bare `LiveRaceState` at the given phase (with/without a heat on the timer). + const liveAt = (phase: LiveRaceState['phase'], heat: string | undefined = 'heat-1') => + ({ current_heat: heat, phase }) as LiveRaceState; + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts ticking when the phase becomes Running', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Armed') }); + render(LiveRaceControl, { session }); + // Idle/pre-race: clock sits at zero. + expect(clockText()).toBe('0:00.000'); + + pushLive(liveAt('Running')); + await tick(); + // Advance wall-clock + the tick interval; the clock reflects the elapsed time. The + // display only updates on a 50ms tick, so we advance by exact tick multiples. + await vi.advanceTimersByTimeAsync(1_250); + expect(clockText()).toBe('0:01.250'); + + await vi.advanceTimersByTimeAsync(60_000); + expect(clockText()).toBe('1:01.250'); + }); + + it('freezes the clock when the phase becomes Finished, and stops ticking', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(LiveRaceControl, { session }); + await tick(); + + await vi.advanceTimersByTimeAsync(2_500); + expect(clockText()).toBe('0:02.500'); + + // Finishing freezes the displayed value… + pushLive(liveAt('Finished')); + await tick(); + expect(clockText()).toBe('0:02.500'); + + // …and the interval is gone: more wall-clock time does not move the clock. + await vi.advanceTimersByTimeAsync(5_000); + expect(clockText()).toBe('0:02.500'); + }); + + it('keeps the frozen value through Scored', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(LiveRaceControl, { session }); + await tick(); + await vi.advanceTimersByTimeAsync(3_000); + + pushLive(liveAt('Finished')); + await tick(); + pushLive(liveAt('Scored')); + await tick(); + await vi.advanceTimersByTimeAsync(4_000); + expect(clockText()).toBe('0:03.000'); + }); + + it('resets the clock to zero when the heat goes back to Scheduled (e.g. after an abort)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(LiveRaceControl, { session }); + await tick(); + await vi.advanceTimersByTimeAsync(2_000); + expect(clockText()).toBe('0:02.000'); + + // An Abort/Restart folds the phase back to Scheduled → reset to zero. + pushLive(liveAt('Scheduled')); + await tick(); + expect(clockText()).toBe('0:00.000'); + }); + + it('resets to zero when there is no heat on the timer', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(LiveRaceControl, { session }); + await tick(); + await vi.advanceTimersByTimeAsync(1_500); + + // No current heat → phase defaults to Scheduled → reset. + pushLive(liveAt('Scheduled', undefined)); + await tick(); + expect(clockText()).toBe('0:00.000'); + }); + + it('does not restart the clock on a repeated Running push (rapid same-phase flips)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(LiveRaceControl, { session }); + await tick(); + await vi.advanceTimersByTimeAsync(2_000); + expect(clockText()).toBe('0:02.000'); + + // Another Running snapshot (e.g. progress update) must not reset the start. + pushLive(liveAt('Running')); + await tick(); + await vi.advanceTimersByTimeAsync(1_000); + expect(clockText()).toBe('0:03.000'); + }); + }); }); From e186bdbeefa46df952275cf0f7833dcae8298126 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:45:45 +0000 Subject: [PATCH 030/362] Typed 404 for unknown API routes + HTTP join-token mint (#63, #64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two protocol-server follow-ups on the v0.4 read/control surface. #64 — smart fallback: an unmatched path under a known API tree (/health, /snapshot, /stream, /control, /auth) now returns a typed `ProtocolError { code: UnknownScope }` 404 JSON instead of the blanket SPA `index.html`. `is_api_path` names the API prefixes; `api_fallback` is the protocol router's own fallback (so the bare server router 404s every unmatched path — correct API-only behaviour); `smart_fallback` wraps the Director's SPA `ServeDir` so a mistyped API path → typed 404 while a genuine client-side route still resolves to the SPA shell. The Director wires `fallback_service(smart_fallback(serve_dir))`. #63 — `POST /auth/join-token`: an RD-gated endpoint (reuses the `ControlAuth` extractor) that mints a fresh read-only join token via `TokenStore::issue_join_token` and returns it as a new wire type `JoinTokenResponse { token }` (serde + ts-rs, exported to bindings/). A non-RD caller (no/unknown/read-only token) → 401 Unauthorized; the minted token authenticates a read but is rejected on control. Tests: server unit/integration tests for both (typed-404 on bad /snapshot + /control paths, a real route still works, smart_fallback serves the SPA for non-API routes; RD mints a join token that reads but cannot control, non-RD mint → 401). Contract suite updated — snapshot asserts a wrong API route is a typed 404 (not the SPA) and a non-API route still falls through to the SPA; control mints a join token over the wire and asserts it is read-only (rejected on control). New JoinTokenResponse binding committed. `cargo xtask ci` green; `npm run contract` green (34 tests). Part of #63, #64. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/JoinTokenResponse.ts | 16 ++ crates/app/src/director.rs | 15 +- crates/server/Cargo.toml | 5 + crates/server/src/app.rs | 316 ++++++++++++++++++++++- crates/server/src/auth.rs | 16 ++ frontend/contract/control.contract.ts | 59 ++++- frontend/contract/snapshot.contract.ts | 37 ++- frontend/packages/types/src/generated.ts | 1 + 8 files changed, 437 insertions(+), 28 deletions(-) create mode 100644 bindings/JoinTokenResponse.ts 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/crates/app/src/director.rs b/crates/app/src/director.rs index c1eead3..48a4f0a 100644 --- a/crates/app/src/director.rs +++ b/crates/app/src/director.rs @@ -23,7 +23,7 @@ use std::path::{Path, PathBuf}; use axum::Router; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; -use gridfpv_server::app::{AppState, router}; +use gridfpv_server::app::{AppState, router, smart_fallback}; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; @@ -101,8 +101,12 @@ pub fn default_assets_dir() -> PathBuf { /// `/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. Finally [`CorsLayer::permissive`] is applied so a -/// different-origin client (the Tauri RD app) can call the API and upgrade the WS. +/// 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` @@ -119,8 +123,9 @@ pub fn build_app(state: AppState, assets: &Path) -> Router { let serve_dir = ServeDir::new(assets).fallback(spa_fallback(index_html)); router(state) - // Anything the protocol router does not handle falls through to the SPA. - .fallback_service(serve_dir) + // 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(serve_dir)) // Permissive CORS so the cross-origin Tauri RD app can reach the API + WS. .layer(CorsLayer::permissive()) } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index bcaaaf4..12f3829 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -34,6 +34,11 @@ ts-rs = "12" 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). diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index e3d7493..de1c0bb 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -74,10 +74,11 @@ use std::sync::{Arc, Mutex}; use axum::Json; use axum::Router; +use axum::body::Body; use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; +use axum::http::{Request, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use gridfpv_engine::scoring::{WinCondition, score_events}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{LapList, lap_list_marshaled, registrations}; @@ -85,7 +86,8 @@ use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; use tokio::sync::Notify; -use crate::auth::TokenStore; +use crate::auth::{JoinTokenResponse, TokenStore}; +use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; use crate::live_state::live_state; use crate::scope::{ClassId, EventId, PilotId}; @@ -265,10 +267,123 @@ pub fn router(state: AppState) -> Router { .route("/snapshot/class/{event}/{class}", get(snapshot_class)) .route("/snapshot/heat/{heat}", get(snapshot_heat)) .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot)) - .route("/stream", get(crate::ws::stream_handler)); + .route("/stream", get(crate::ws::stream_handler)) + // RD-gated mint of a read-only join token (#63): an authenticated RD trades its RD + // token for a fresh spectator (read-only) token to share (e.g. a venue QR). + .route("/auth/join-token", post(mint_join_token)); // The privileged RD control surface (§5) is composed on separately so #44 can wrap // just these routes in its auth layer. - crate::control_handler::control_routes(read).with_state(state) + 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(state) +} + +/// `POST /auth/join-token` — mint a fresh **read-only** join token (protocol.html §5, +/// §9.4) — issue #63. +/// +/// [`ControlAuth`] runs first: only an authenticated **RD** (a valid `Authorization: +/// Bearer `) may mint one; any other caller — no token, a read-only/join token, +/// an unknown or revoked token — is [`ErrorCode::Unauthorized`] → HTTP 401 (the extractor +/// rejects before the handler body runs). On success a brand-new read-only token is issued +/// from the shared [`TokenStore`] and returned as a [`JoinTokenResponse`]; the spectator +/// who later presents it authenticates LAN reads but is rejected on control. +async fn mint_join_token( + _auth: ControlAuth, + State(state): State, +) -> Json { + let token = state.tokens().issue_join_token(); + 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 { + const API_PREFIXES: [&str; 5] = ["/health", "/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 @@ -487,9 +602,9 @@ impl IntoResponse for ProtocolError { #[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 axum::body::Body; - use axum::http::Request; use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass}; use gridfpv_projection::CompetitorKey; use gridfpv_storage::InMemoryLog; @@ -787,4 +902,191 @@ mod tests { 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(state: AppState, uri: &str) -> (StatusCode, Option) { + let response = router(state) + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let 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 (state, _) = state_with(recorded_heat()); + let (status, err) = get_raw(state, "/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 (state, _) = state_with(recorded_heat()); + let (status, err) = get_raw(state, "/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 (state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(state, "/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); + } + + // --- #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( + state: AppState, + token: Option<&str>, + ) -> (StatusCode, Option) { + let mut builder = Request::builder().method("POST").uri("/auth/join-token"); + if let Some(token) = token { + builder = builder.header("Authorization", format!("Bearer {token}")); + } + let response = router(state) + .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 (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(state.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() { + let (state, _) = state_with(recorded_heat()); + + // No token → 401. + let (status, _) = post_join_token(state.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(state.clone(), Some(&join)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // An unknown token → 401. + let (status, _) = post_join_token(state, Some("not-a-real-token")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } } diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 71eb6a1..a764528 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -50,9 +50,25 @@ 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 diff --git a/frontend/contract/control.contract.ts b/frontend/contract/control.contract.ts index e31cae3..c478d99 100644 --- a/frontend/contract/control.contract.ts +++ b/frontend/contract/control.contract.ts @@ -8,18 +8,21 @@ * well-formed `{ ok: false, error: ProtocolError(BadRequest) }` (NOT an HTTP error); and the * consequence of a command reaches a `/stream` subscriber on the read path. * - seam 6 → auth: control with no token / an unknown (revoked-equivalent) token is `401`; a - * valid RD token is accepted; reads (`/snapshot`, `/stream`) are open with no token. + * valid RD token is accepted; reads (`/snapshot`, `/stream`) are open with no token; and an + * RD can mint a read-only **join token** over the wire (`POST /auth/join-token`, #63) that + * authenticates a READ but is rejected on CONTROL. * - * NOTE (recorded gap, not a failure): the server exposes **no wire endpoint to mint a - * read-only join token** — `issue_join_token` exists in `crates/server/src/auth.rs` but is not - * reachable over HTTP, and only the single `GRIDFPV_RD_TOKEN` is pinned. So the "a read-only - * join-token is rejected on control" arm of seam 6 cannot be exercised over the wire here; it - * is covered by the Rust unit test `auth::tests::join_token_is_read_only_and_rejected_on_control`. - * We assert the reachable equivalent: an unknown token (which a revoked token becomes) is 401. + * NOTE (#63, formerly a recorded gap): the server now exposes `POST /auth/join-token` — an + * RD-gated endpoint that mints a fresh read-only join token (`issue_join_token`) as a + * `JoinTokenResponse`. So the "a read-only join-token is rejected on control" arm of seam 6 is + * exercised over the real wire here (mint with the RD token, then assert the minted token is + * accepted on a read and rejected — 401 — on control). The Rust unit test + * `auth::tests::join_token_is_read_only_and_rejected_on_control` still covers the store-level + * invariant. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Command } from '@gridfpv/types'; +import type { Command, JoinTokenResponse } from '@gridfpv/types'; import { type Director } from '../test-harness/director.ts'; import { @@ -206,4 +209,44 @@ describe('seam 6: auth gates control, reads stay open', () => { expect((frames[0] as { code?: string } | undefined)?.code).not.toBe('Unauthorized'); ws.close(); }); + + it('minting a join token requires the RD token — no/bad token → 401 (#63)', async () => { + // No Authorization → 401. + const anon = await fetch(`${director.baseUrl}/auth/join-token`, { method: 'POST' }); + expect(anon.status).toBe(401); + // An unknown token → 401. + const bad = await fetch(`${director.baseUrl}/auth/join-token`, { + method: 'POST', + headers: { Authorization: 'Bearer not-a-real-token' } + }); + expect(bad.status).toBe(401); + }); + + it('an RD mints a read-only join token that reads but is REJECTED on control (#63)', async () => { + // The RD trades its RD token for a fresh read-only join token over the wire. + const res = await fetch(`${director.baseUrl}/auth/join-token`, { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` } + }); + expect(res.status).toBe(200); + const { token: join } = (await res.json()) as JoinTokenResponse; + expect(typeof join).toBe('string'); + expect(join.length).toBeGreaterThan(0); + expect(join).not.toBe(TOKEN); // a distinct, freshly-minted credential + + // The minted join token authenticates a READ (reads accept a valid token of either role). + const read = await fetch(`${director.baseUrl}/snapshot/event/any`, { + headers: { Authorization: `Bearer ${join}` } + }); + expect(read.status).toBe(200); + + // …but it is REJECTED on CONTROL — a spectator can watch, never run the race. + const { status, body } = await postControl( + director.baseUrl, + { ScheduleHeat: { heat: 'h-join-rejected', lineup: [] } }, + { token: join } + ); + expect(status).toBe(401); + expect((body as { code?: string }).code).toBe('Unauthorized'); + }); }); diff --git a/frontend/contract/snapshot.contract.ts b/frontend/contract/snapshot.contract.ts index 52aeadc..164ebf3 100644 --- a/frontend/contract/snapshot.contract.ts +++ b/frontend/contract/snapshot.contract.ts @@ -8,8 +8,9 @@ * broke comparison + rendering; v0.4 bug class). * * Asserts the real wire: each correct path returns `200` + the right externally-tagged - * `ProjectionBody` variant + a numeric `cursor`; a wrong/unknown route does NOT return a - * parseable `Snapshot` (it is the SPA fallback, not a silent 200 Snapshot); and every + * `ProjectionBody` variant + a numeric `cursor`; a wrong/unknown **API** route returns a + * typed `ProtocolError` 404 (the smart fallback #64), never the SPA shell or a silent 200 + * Snapshot, while a genuine non-API client route still falls through to the SPA; and every * integer field is `typeof === 'number'`. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -119,14 +120,34 @@ describe('seam 1: snapshot routes are path-scoped', () => { expect((json as { code?: string }).code).toBe('UnknownScope'); }); - it('a WRONG route (`?scope=` style) is NOT a Snapshot — it is the SPA fallback', async () => { - // THE path-vs-scope bug: a client that addressed the snapshot by a `?scope=` query (or - // any path the protocol router does not own) falls through to the SPA `fallback_service`. - // With no built console that is a 404 text body; with one it is `index.html` (200). Either - // way it is NOT a parseable `Snapshot`, so a client must address by PATH, never `?scope=`. + it('a WRONG API route → typed 404 ProtocolError, NOT the SPA shell (#64)', async () => { + // THE path-vs-scope bug, now fixed by the smart fallback (#64): a client that addressed + // the snapshot by a `?scope=` query (path `/snapshot`) — or any path under a known API + // tree the protocol router does not concretely own — gets a typed `ProtocolError` 404 + // JSON, NOT the SPA `index.html`. So a mistyped API call is an honest, parseable error a + // client can branch on, never an HTML 200 it would choke on. It is still NOT a `Snapshot`, + // so a client must address by PATH, never `?scope=`. const wrong = await getSnapshot('/snapshot?scope=event:spring-cup'); + expect(wrong.status).toBe(404); + expect((wrong.json as { code?: string }).code).toBe('UnknownScope'); expect(isSnapshot(wrong.json)).toBe(false); - expect(wrong.text).not.toContain('"cursor"'); + + // A bad nested API path is likewise a typed 404, not the SPA. + const garbage = await getSnapshot('/snapshot/zzz/nope/extra'); + expect(garbage.status).toBe(404); + expect((garbage.json as { code?: string }).code).toBe('UnknownScope'); + }); + + it('a NON-API client route still falls through to the SPA shell, not a typed 404 (#64)', async () => { + // The smart fallback only typed-404s *API-tree* paths; a genuine client-side route (a + // deep link the SPA router owns) still resolves to the SPA shell. The contract Director + // points `GRIDFPV_ASSETS` at an empty dir, so the unbuilt-console fallback is a 404 text + // body — but, crucially, it is NOT a `ProtocolError` JSON (no `code`), proving the path + // took the SPA branch, not the API-404 branch. + const spa = await getSnapshot('/heats/q-1/live'); + expect((spa.json as { code?: string } | undefined)?.code).toBeUndefined(); + expect(isSnapshot(spa.json)).toBe(false); + expect(spa.text).not.toContain('"cursor"'); }); }); diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 8d0b8de..b8060d8 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -47,6 +47,7 @@ export type * from '@bindings/HeatPhase'; export type * from '@bindings/HeatResult'; export type * from '@bindings/HeatTransition'; export type * from '@bindings/Hello'; +export type * from '@bindings/JoinTokenResponse'; export type * from '@bindings/Lap'; export type * from '@bindings/LapList'; export type * from '@bindings/LiveRaceState'; From 130b95a259f45fd9a327287f068b40a78d53279b Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:50:49 +0000 Subject: [PATCH 031/362] =?UTF-8?q?docs:=20v0.4=20doc-reconciliation=20pas?= =?UTF-8?q?s=20=E2=80=94=20wire=20types,=20join-token,=20contract=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record what was built since the last reconciliation pass against the HTML design docs (#59). protocol.html - §2 Endpoint surface: add POST /auth/join-token (RD-gated, mints a read-only JoinTokenResponse { token }); note unknown API routes under /snapshot, /stream, /control, /auth, /health return a typed ProtocolError 404, not the SPA shell. - §3: callout affirming the now-tested read/realtime/control contract is enforced by a strict contract suite (real client + raw HTTP/WS vs the real Director), motivated by five integration-seam bugs hidden behind mocks. - §6: callout for the wire data-type convention — every transferred integer (i64 µs times/durations, u64 cursors/sequences/log-offsets) renders to TS as a plain number, not bigint; string reserved for any full-range u64. code-conventions.html - §3 Generated wire types: add the number-vs-bigint convention after the ts-rs sentence. testing-strategy.html - New §6 "frontend wire-contract suite & observability harness": the real-client-↔-real-Director contract suite (frontend/contract/, npm run contract, no Docker/browser, CI-able) and the observability harness (frontend/test-harness/, npm run observe) with the debug-with-full-stack-observability-first rule. Renumbered the following sections (format generators §7, test levels §8, open decisions §9) and updated the internal §6→§7 cross-reference. Part of #59 (v0.4 doc reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/code-conventions.html | 5 +++++ docs/protocol.html | 33 +++++++++++++++++++++++++++++++++ docs/testing-strategy.html | 37 +++++++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/code-conventions.html b/docs/code-conventions.html index a2f291e..c73a8ab 100644 --- a/docs/code-conventions.html +++ b/docs/code-conventions.html @@ -63,6 +63,11 @@

    3. Generated wire types

    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

    diff --git a/docs/protocol.html b/docs/protocol.html index 066c05f..50aa69a 100644 --- a/docs/protocol.html +++ b/docs/protocol.html @@ -151,8 +151,15 @@

    Endpoint surface (as built, v0.4)

    +
    GET /snapshot/pilot/{event}/{pilot}pilot snapshot
    GET /streamWS change stream (SubscribeRequestStreamMessage)
    GET /control (WS) and POST /control (one-shot)RD-bearer-gated; CommandCommandAck
    POST /auth/join-tokenRD-gated; mints a read-only JoinTokenResponse { token } — the QR/URL spectator capability (§5)
    +

    + Unknown API routes under /snapshot, /stream, + /control, /auth, and /health return a typed + ProtocolError 404 — not the SPA shell — so a contract miss surfaces as a + structured error rather than an HTML page a client can't parse. +

    3. The realtime change stream

    @@ -212,6 +219,20 @@

    Sequence, ordering & resume

    exactly-once effect. +
    +

    + As built (v0.4) the contract is enforced, not just described. The + read / realtime / control contract is covered by a strict contract suite + — the real protocol client plus raw HTTP/WS, both run against the real Director — asserting + the path-scoped snapshot routes (§4), the externally-tagged StreamMessage + stream frames (§3), the per-stream sequence-vs-snapshot cursor + distinction (§9.5), the control path (headers + auth + acks, §5), and that the wire carries + plain numbers (§9). It exists because five integration-seam bugs + — all contract drift that had hidden behind mocks, where each side tested its own assumption + of the contract rather than the contract itself — motivated pinning the real wire behaviour. +

    +
    +
     sequenceDiagram
       participant C as Client (PWA / overlay)
    @@ -370,6 +391,18 @@ 

    6. The contract defined once, in Rust

    as a build step that emits the TS module the frontend imports; keeping it offline and reproducible is part of the build story (deferred to the Roadmap).

    +
    +

    + As built (v0.4): every transferred integer is a plain number. + Microsecond times / durations (i64) and cursors / sequences / log-offsets + (u64) all render to TypeScript as a plain number, not a + bigint. These values are bounded far below + Number.MAX_SAFE_INTEGER (≈9e15) in our domain, so a JS number + round-trips them exactly, and serde already serialises them as JSON numbers — so the type + matches the wire. A genuinely full-range u64 (e.g. a random id) would instead + be carried as a string; we have none today. +

    +

    7. Versioning & evolution

    diff --git a/docs/testing-strategy.html b/docs/testing-strategy.html index 4ded3f3..ad0b06d 100644 --- a/docs/testing-strategy.html +++ b/docs/testing-strategy.html @@ -309,7 +309,36 @@

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

    canonical-log fixtures (§4).

    -

    6. Testing the format generators

    +

    6. The frontend wire-contract suite & observability harness

    +

    + Two test layers landed in v0.4 that sit between the mocked frontend unit tests and + the dockerized mock-RH end-to-end run, and they exist for one reason: the v0.4 integration-seam + bugs all passed their unit tests — each side mocked its own assumption of the + contract, so the drift between the two sides was exactly what nothing tested. +

    +
    +

    A real-client ↔ real-Director contract suite at every seam. + The contract suite (frontend/contract/, npm run contract) runs the + real protocol client against the real Director and asserts actual wire + behaviour — the path-scoped snapshot routes, the externally-tagged StreamMessage + frames, the per-stream-sequence-vs-snapshot-cursor distinction + (Protocol §9.5), the control path (headers + auth + acks), and + that the wire carries numbers. It is the layer between the mocked unit tests and + the dockerized mock-RH e2e, and it needs no Docker and no browser, so it runs + in the shared CI pipeline. It exists because the v0.4 seam bugs each passed their own unit + tests while drifting from the contract.

    +
    +
    +

    Debug with full-stack observability first. A reusable harness + (frontend/test-harness/, npm run observe) boots the real Director and + captures the browser console, page errors, WS frames, and server logs together, + dumping all of them on any failure. The standing rule that follows: when something breaks, + look at full-stack observability — browser console + server logs + wire — before + forming a hypothesis. A render-time fault (a thrown exception during render) then + surfaces immediately in the console capture instead of presenting as a silent blank page.

    +
    + +

    7. Testing the format generators

    Format / qualifying generators are a pure function of state too (Race Engine §3): given the seeded field, config, and the @@ -338,7 +367,7 @@

    6. Testing the format generators

    this same interface.

    -

    7. Test levels

    +

    8. Test levels

    The levels mirror the Director's data path — translate in, derive, serve out — and each proves a different claim. Nothing above unit level touches a live source; replay is the @@ -348,7 +377,7 @@

    7. Test levels

    Unit
    Pure functions in isolation: adapter translators (golden cases, §2), the derivation stages (passes→laps→results→standings, §4), and the - format generators (table-driven, §6). Fast, hermetic, the bulk of the + format generators (table-driven, §7). Fast, hermetic, the bulk of the suite.
    Integration — replay
    A recorded session (raw or canonical) replayed through the assembled adapter → @@ -392,7 +421,7 @@

    7. Test levels

    contract -.-> e2e
    -

    8. Open decisions

    +

    9. Open decisions

    1. Resolved — Fixture format & storage. Fixtures are stored as JSON and checked in alongside the code: per-adapter source/canonical fixtures From 8aa5689abe82c683ef983319d24e3aa7c5430de2 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 15:52:15 +0000 Subject: [PATCH 032/362] =?UTF-8?q?docs(conventions):=20correct=20SourceTi?= =?UTF-8?q?me=20ts-rs=20rendering=20note=20(i64=E2=86=92number)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/code-conventions.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/code-conventions.html b/docs/code-conventions.html index c73a8ab..6dd544d 100644 --- a/docs/code-conventions.html +++ b/docs/code-conventions.html @@ -79,8 +79,8 @@

      4. Safety & the data model

      Integer-microsecond source time. Time is carried as 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 From c09affaee53ad639ecec835f8643a8f370e9960f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 16:49:46 +0000 Subject: [PATCH 033/362] Pre-stub format modules: double_elim, round_robin, multi_main (generator wave base) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/double_elim.rs | 1 + crates/engine/src/lib.rs | 3 +++ crates/engine/src/multi_main.rs | 1 + crates/engine/src/round_robin.rs | 1 + 4 files changed, 6 insertions(+) create mode 100644 crates/engine/src/double_elim.rs create mode 100644 crates/engine/src/multi_main.rs create mode 100644 crates/engine/src/round_robin.rs diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs new file mode 100644 index 0000000..6eabbb8 --- /dev/null +++ b/crates/engine/src/double_elim.rs @@ -0,0 +1 @@ +//! double_elim format generator — implements format::Generator (filled by the agent). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5b23756..e39ecee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,9 @@ pub mod schedule; pub mod scoring; // Concrete formats on the `format::Generator` interface (#33/#34/#35). +pub mod double_elim; +pub mod multi_main; +pub mod round_robin; pub mod single_elim; pub mod timed_qual; pub mod zippyq; diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs new file mode 100644 index 0000000..128e13a --- /dev/null +++ b/crates/engine/src/multi_main.rs @@ -0,0 +1 @@ +//! multi_main format generator — implements format::Generator (filled by the agent). diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs new file mode 100644 index 0000000..13f30f6 --- /dev/null +++ b/crates/engine/src/round_robin.rs @@ -0,0 +1 @@ +//! round_robin format generator — implements format::Generator (filled by the agent). From 7dcad73e15e1d2e8e4b2a480b919e8676e6d9432 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 16:57:41 +0000 Subject: [PATCH 034/362] =?UTF-8?q?Add=20multi-tier=20mains=20(A/B/C?= =?UTF-8?q?=E2=80=A6)=20format=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the MultiGP-standard tiered finals as a `Generator`: partition a seeded (qualifying-ranked) field into an A main (top `main_size`), B main (next `main_size`), C main, … down to a possibly-short last main, race each as an independent heat (`main-A`/`main-B`/…), and concatenate their finishing orders in tier order so the worst A-main finisher still outranks the best B-main finisher. - `MultiMain` (`new`/`from_config`/`register` under `"multi_main"`, `main_size` default 4). `next` emits every main at once (mains are independent) and Completes once all are scored; `ranking` concatenates each main's order in tier bands. Seed order is the qualifying rank, with `SeedingOutcome` applied at construction. Short last main and a field <= `main_size` (single A main) handled deterministically. Bump-up variant noted as future work. - Table tests: tiering/short-main/single-main, all-mains-at-once, completion, tier-order concatenation, B-winner-below-A-last, provisional ranking, determinism, registry + main_size param. - Mock-RH e2e (`multi_main_live.rs`, feature `live`, `#[ignore]`, port 5044): 6 pilots, main_size 3 (A+B) over real heats; asserts termination and the whole A main ranked above the whole B main. Part of #13 (multi-tier mains generator). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/multi_main.rs | 465 ++++++++++++++++++++++++- crates/engine/tests/multi_main_live.rs | 207 +++++++++++ 2 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/multi_main_live.rs diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs index 128e13a..0ff33ba 100644 --- a/crates/engine/src/multi_main.rs +++ b/crates/engine/src/multi_main.rs @@ -1 +1,464 @@ -//! multi_main format generator — implements format::Generator (filled by the agent). +//! Multi-tier mains generator (#13) — the MultiGP-standard tiered finals: partition a +//! qualifying-ranked field into an A main, a B main, a C main … and race each as one +//! heat, then concatenate their finishing orders into the overall standing. +//! +//! # The format (race-engine.html §3, §5) +//! +//! Multi-tier mains is the classic club finals format. After qualifying produces a +//! **seeded field** (the seed order *is* the qualifying rank), the field is sliced into +//! fixed-size tiers, best seeds first: +//! +//! - the **A main** is the top `main_size` seeds, +//! - the **B main** the next `main_size`, +//! - the **C main** the next, and so on, +//! - down to a possibly-**short last main** when the field does not divide evenly. +//! +//! Each main is an independent heat (`main-A`, `main-B`, …). The mains do not feed each +//! other — there is no advancement between them in v1 — so the generator emits **all of +//! them at once** as a single [`GeneratorStep::Run`]; the heat loop runs them in any +//! order and feeds every result back. (See "bump-up" below for the variant where a lower +//! main's winner advances up.) +//! +//! # Final ranking — concatenation in tier order +//! +//! The overall standing is each main's finishing order, **concatenated A, then B, then +//! C…**: the *worst* finisher of the A main still ranks above the *best* finisher of the +//! B main, and so on down the tiers. This is the defining property of the format — your +//! main placement is bounded by the tier you qualified into. Positions are dense and +//! tie-aware via [`rank_by`], but because each competitor lands in exactly one tier and +//! the tiers are laid out in strict order, the bands never overlap. +//! +//! # Determinism (race-engine.html §6) +//! +//! Tiering is a pure slice of the seeded field (the recorded [`SeedingOutcome`] applied +//! once at construction); the ranking is a pure projection of the completed mains. No +//! clock, no RNG. Same field + same results → same heats and same standing, every replay. +//! +//! # Future: the bump-up variant +//! +//! A common variant lets the **top finisher(s) of a lower main bump up** into the next +//! main (B's winner earns a slot in A, etc.), which turns the independent mains into a +//! dependent ladder run bottom-up. v1 is deliberately **straight tiered mains** (mains +//! are independent, emitted together). Bump-up is a future option (it would emit the +//! lowest main first and seed each higher main from the one below); the trait surface +//! does not change. +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use gridfpv_events::CompetitorRef; + +use crate::format::{ + CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry, + rank_by, result_ranking, +}; + +/// Multi-tier mains over a seeded (qualifying-ranked) field. +/// +/// Constructed with the seeded field (the recorded [`SeedingOutcome`] already applied, +/// so the field order *is* the qualifying rank) and a `main_size`. `next` emits the A/B/C +/// … main heats; `ranking` concatenates their results in tier order. See the module docs. +/// +/// [`SeedingOutcome`]: crate::format::SeedingOutcome +pub struct MultiMain { + /// The field in qualifying-rank order (best seed first; recorded draw already applied). + field: Vec, + /// Competitors per main (the A/B/C… tier size). Clamped to a minimum of 1. + main_size: usize, +} + +impl MultiMain { + /// The format name this registers under. + pub const NAME: &'static str = "multi_main"; + + /// The default tier size (a standard 4-up main). + pub const DEFAULT_MAIN_SIZE: usize = 4; + + /// Build over a `field` in qualifying-rank order with the given `main_size` (clamped + /// to a minimum of 1 — a main needs at least one competitor). + pub fn new(field: Vec, main_size: usize) -> Self { + Self { + field, + main_size: main_size.max(1), + } + } + + /// The registry constructor: applies the recorded `seeding` draw to `field` and reads + /// the optional `main_size` param (default [`DEFAULT_MAIN_SIZE`](Self::DEFAULT_MAIN_SIZE)). + pub fn from_config(config: &FormatConfig) -> Box { + let field = config.seeding.apply(&config.field); + let main_size = config.param_usize("main_size", Self::DEFAULT_MAIN_SIZE); + Box::new(Self::new(field, main_size)) + } + + /// Register this format under [`NAME`](Self::NAME). + pub fn register(registry: &mut FormatRegistry) { + registry.register(Self::NAME, Self::from_config); + } + + /// The tier letter for main index `index` (0-based): 0 → `A`, 1 → `B`, … For more + /// tiers than the alphabet holds (>26 mains, vanishingly unlikely) it falls back to + /// the raw index so ids stay unique and deterministic. + fn tier_label(index: usize) -> String { + if index < 26 { + ((b'A' + index as u8) as char).to_string() + } else { + index.to_string() + } + } + + /// The heat id for the main at `index`: `main-A`, `main-B`, … + fn main_id(index: usize) -> String { + format!("main-{}", Self::tier_label(index)) + } + + /// The tiers as a list of `(heat_id, lineup)` pairs, best tier first. + /// + /// Slices the seeded field into chunks of `main_size`; the final chunk may be **short** + /// (a short last main) when the field does not divide evenly, and a field of `main_size` + /// or fewer yields a single A main. Pure and deterministic. + fn tiers(&self) -> Vec<(String, Vec)> { + self.field + .chunks(self.main_size) + .enumerate() + .map(|(index, chunk)| (Self::main_id(index), chunk.to_vec())) + .collect() + } +} + +impl Generator for MultiMain { + fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { + let tiers = self.tiers(); + // An empty field has no mains to run — it is complete immediately. + if tiers.is_empty() { + return GeneratorStep::Complete; + } + + // The mains are independent, so emit them all at once. Once every main has a + // recorded result, the format is complete; until then, (re-)emit the full set — + // `next` stays a pure function of the completed history. + let scored: std::collections::BTreeSet<&str> = + completed.iter().map(|c| c.heat.0.as_str()).collect(); + let all_scored = tiers.iter().all(|(id, _)| scored.contains(id.as_str())); + if all_scored { + GeneratorStep::Complete + } else { + GeneratorStep::Run( + tiers + .into_iter() + .map(|(id, lineup)| HeatPlan::new(id, lineup)) + .collect(), + ) + } + } + + fn ranking(&self, completed: &[CompletedHeat]) -> Vec { + let by_id: BTreeMap<&str, &CompletedHeat> = + completed.iter().map(|c| (c.heat.0.as_str(), c)).collect(); + + // Walk the tiers best-first; each tier occupies its own band, so concatenating the + // bands puts every A-main finisher above every B-main finisher, and so on. A tier + // whose heat has not come back yet falls back to its seed order (provisional). + // + // Rows carry a (band, in_band_key) sort key so `rank_by` shares positions only + // *within* a tier; the competitor ref is the final deterministic tie-break. + let mut rows: Vec<(CompetitorRef, (u32, u32))> = Vec::new(); + for (band, (id, lineup)) in self.tiers().into_iter().enumerate() { + let band = band as u32; + match by_id.get(id.as_str()) { + Some(heat) => { + for entry in result_ranking(&heat.result) { + rows.push((entry.competitor, (band, entry.position))); + } + } + None => { + // Provisional: this main has not been scored — keep its seed order. + for (i, competitor) in lineup.into_iter().enumerate() { + rows.push((competitor, (band, i as u32 + 1))); + } + } + } + } + rank_by(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::SeedingOutcome; + use crate::scoring::{HeatResult, Metric, Placement}; + use gridfpv_events::AdapterId; + use gridfpv_projection::CompetitorKey; + + const ADAPTER: &str = "demo"; + + fn cref(name: &str) -> CompetitorRef { + CompetitorRef(name.into()) + } + + fn field(names: &[&str]) -> Vec { + names.iter().map(|n| cref(n)).collect() + } + + /// Build a `HeatResult` from `(name, position, laps)` rows. + fn result(rows: &[(&str, u32, u32)]) -> HeatResult { + HeatResult { + places: rows + .iter() + .map(|(name, position, laps)| Placement { + competitor: CompetitorKey { + adapter: AdapterId(ADAPTER.into()), + competitor: cref(name), + }, + position: *position, + laps: *laps, + metric: Metric::LastLapAt(None), + }) + .collect(), + } + } + + fn names(entries: &[RankEntry]) -> Vec { + entries.iter().map(|e| e.competitor.0.clone()).collect() + } + + /// The lineups of a `Run` step (panics if the step is `Complete`). + fn lineups(step: &GeneratorStep) -> Vec<(String, Vec)> { + match step { + GeneratorStep::Run(heats) => heats + .iter() + .map(|h| { + ( + h.heat.0.clone(), + h.lineup.iter().map(|c| c.0.clone()).collect(), + ) + }) + .collect(), + GeneratorStep::Complete => panic!("expected Run, got Complete"), + } + } + + // --- Tiering ------------------------------------------------------------ + + #[test] + fn ten_seed_field_splits_into_a_b_and_a_short_c_main() { + // 10 seeds, main_size 4 → A(top 4), B(next 4), C(last 2, short). + let mut g = MultiMain::new( + field(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]), + 4, + ); + assert_eq!( + lineups(&g.next(&[])), + vec![ + ( + "main-A".to_string(), + vec!["1".into(), "2".into(), "3".into(), "4".into()] + ), + ( + "main-B".to_string(), + vec!["5".into(), "6".into(), "7".into(), "8".into()] + ), + ("main-C".to_string(), vec!["9".into(), "10".into()]), + ] + ); + } + + #[test] + fn field_at_or_below_main_size_is_a_single_a_main() { + let mut g = MultiMain::new(field(&["1", "2", "3"]), 4); + assert_eq!( + lineups(&g.next(&[])), + vec![( + "main-A".to_string(), + vec!["1".into(), "2".into(), "3".into()] + )] + ); + } + + #[test] + fn field_dividing_evenly_has_no_short_main() { + let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3); + let ls = lineups(&g.next(&[])); + assert_eq!(ls.len(), 2); + assert_eq!(ls[0].1.len(), 3); + assert_eq!(ls[1].1.len(), 3); + } + + // --- All mains emitted together ----------------------------------------- + + #[test] + fn emits_every_main_at_once() { + let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4); + // A and B emitted in a single Run step (the mains are independent). + assert_eq!(lineups(&g.next(&[])).len(), 2); + } + + // --- Completion --------------------------------------------------------- + + #[test] + fn completes_only_after_every_main_is_scored() { + let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3); + // Only the A main scored so far → still running (B outstanding). + let a_only = vec![CompletedHeat::new( + "main-A", + result(&[("1", 1, 6), ("2", 2, 5), ("3", 3, 4)]), + )]; + assert!(matches!(g.next(&a_only), GeneratorStep::Run(_))); + + // Both mains scored → complete. + let both = vec![ + CompletedHeat::new("main-A", result(&[("1", 1, 6), ("2", 2, 5), ("3", 3, 4)])), + CompletedHeat::new("main-B", result(&[("4", 1, 6), ("5", 2, 5), ("6", 3, 4)])), + ]; + assert_eq!(g.next(&both), GeneratorStep::Complete); + } + + // --- Final ranking: concatenation in tier order ------------------------- + + #[test] + fn final_ranking_concatenates_a_then_b_then_c() { + let mut g = MultiMain::new( + field(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]), + 4, + ); + let completed = vec![ + // A main: finishing order 2, 1, 4, 3. + CompletedHeat::new( + "main-A", + result(&[("2", 1, 8), ("1", 2, 7), ("4", 3, 6), ("3", 4, 5)]), + ), + // B main: finishing order 5, 8, 6, 7. + CompletedHeat::new( + "main-B", + result(&[("5", 1, 8), ("8", 2, 7), ("6", 3, 6), ("7", 4, 5)]), + ), + // C main (short): finishing order 10, 9. + CompletedHeat::new("main-C", result(&[("10", 1, 8), ("9", 2, 7)])), + ]; + assert_eq!(g.next(&completed), GeneratorStep::Complete); + + let ranking = g.ranking(&completed); + // A main first (in its finishing order), then B, then C — concatenated. + assert_eq!( + names(&ranking), + vec!["2", "1", "4", "3", "5", "8", "6", "7", "10", "9"] + ); + // Positions are 1..=10: the worst A finisher (pos 4) outranks the best B + // finisher (pos 5), etc. + for (i, entry) in ranking.iter().enumerate() { + assert_eq!(entry.position, i as u32 + 1); + } + } + + #[test] + fn b_main_winner_ranks_below_the_a_mains_last() { + let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4); + let completed = vec![ + CompletedHeat::new( + "main-A", + result(&[("1", 1, 8), ("2", 2, 7), ("3", 3, 6), ("4", 4, 5)]), + ), + CompletedHeat::new( + "main-B", + result(&[("5", 1, 8), ("6", 2, 7), ("7", 3, 6), ("8", 4, 5)]), + ), + ]; + let ranking = g.ranking(&completed); + // The A-main's last finisher (4) is 4th overall; the B-main's winner (5) is 5th. + let pos = |c: &str| { + ranking + .iter() + .find(|e| e.competitor.0 == c) + .unwrap() + .position + }; + assert_eq!(pos("4"), 4); + assert_eq!(pos("5"), 5); + assert!(pos("4") < pos("5")); + } + + // --- Provisional ranking ------------------------------------------------ + + #[test] + fn provisional_ranking_before_any_heat_is_the_seed_order() { + let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3); + assert_eq!(names(&g.ranking(&[])), vec!["1", "2", "3", "4", "5", "6"]); + } + + #[test] + fn provisional_ranking_uses_scored_mains_and_seed_order_for_the_rest() { + let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3); + // Only A scored (re-ordered 3,1,2); B keeps seed order behind it. + let a_only = vec![CompletedHeat::new( + "main-A", + result(&[("3", 1, 8), ("1", 2, 7), ("2", 3, 6)]), + )]; + assert_eq!( + names(&g.ranking(&a_only)), + vec!["3", "1", "2", "4", "5", "6"] + ); + } + + // --- Determinism -------------------------------------------------------- + + #[test] + fn next_is_deterministic_for_the_same_history() { + let mut g1 = MultiMain::new(field(&["1", "2", "3", "4", "5"]), 2); + let mut g2 = MultiMain::new(field(&["1", "2", "3", "4", "5"]), 2); + assert_eq!(g1.next(&[]), g2.next(&[])); + } + + #[test] + fn seeding_draw_reorders_the_tiers_deterministically() { + // The qualifying rank arrives as a recorded draw; tiering follows that order. + let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])) + .with_seeding(SeedingOutcome::drawn(field(&["3", "1", "4", "2"]))) + .with_param("main_size", "2"); + let mut g1 = MultiMain::from_config(&cfg); + let mut g2 = MultiMain::from_config(&cfg); + let s1 = g1.next(&[]); + assert_eq!(s1, g2.next(&[])); + // Drawn order 3,1,4,2 → A(3,1), B(4,2). + assert_eq!( + lineups(&s1), + vec![ + ("main-A".to_string(), vec!["3".into(), "1".into()]), + ("main-B".to_string(), vec!["4".into(), "2".into()]), + ] + ); + } + + // --- Registry ----------------------------------------------------------- + + #[test] + fn registry_builds_multi_main_with_default_main_size() { + let mut registry = FormatRegistry::new(); + MultiMain::register(&mut registry); + assert_eq!(registry.names(), vec!["multi_main"]); + + // Default main_size 4: 5 seeds → A(4), B(1, short). + let cfg = FormatConfig::new(field(&["1", "2", "3", "4", "5"])); + let mut g = registry + .build(MultiMain::NAME, &cfg) + .expect("multi_main is registered"); + let ls = lineups(&g.next(&[])); + assert_eq!(ls[0].0, "main-A"); + assert_eq!(ls[0].1.len(), 4); + assert_eq!(ls[1].0, "main-B"); + assert_eq!(ls[1].1, vec!["5".to_string()]); + } + + #[test] + fn registry_reads_the_main_size_param() { + let mut registry = FormatRegistry::new(); + MultiMain::register(&mut registry); + let cfg = + FormatConfig::new(field(&["1", "2", "3", "4", "5", "6"])).with_param("main_size", "3"); + let mut g = registry.build(MultiMain::NAME, &cfg).unwrap(); + let ls = lineups(&g.next(&[])); + assert_eq!(ls.len(), 2); + assert_eq!(ls[0].1.len(), 3); + assert_eq!(ls[1].1.len(), 3); + } +} diff --git a/crates/engine/tests/multi_main_live.rs b/crates/engine/tests/multi_main_live.rs new file mode 100644 index 0000000..5547805 --- /dev/null +++ b/crates/engine/tests/multi_main_live.rs @@ -0,0 +1,207 @@ +//! Multi-tier mains end-to-end test (#13) — real tiered mains over real mock-RH heats. +//! +//! Drives a small multi-main final (6 pilots, `main_size` 3 → an A main and a B main) +//! where every main is a **real** dockerized RotorHazard heat run through the shared +//! [`common::run_mock_heat`] harness. The [`MultiMain`] generator emits both mains in one +//! [`GeneratorStep::Run`]; for each we seat the lineup onto RotorHazard nodes (a busy +//! continuously-lapping stream for the intended in-main winner, DNF streams for the rest), +//! run the heat, score it with [`score_events`], translate the node placements back onto +//! the main's competitor refs, and feed every [`CompletedHeat`] back. We assert the format +//! terminates and the final ranking orders the **whole A main above the whole B main** — +//! the defining property of tiered mains. +//! +//! As with the other format e2es, the mock reads its CSV continuously (lap timing is not +//! controllable), so this is **structural / tolerant**: we rely only on "the busy node +//! out-laps the DNF nodes", never on exact lap times. +//! +//! For RH the canonical pass `at` is **ms since race start**, so the timed clock starts at +//! zero (`race_start = SourceTime::from_micros(0)`). +//! +//! Local-only class (needs Docker). DISTINCT port 5044. Run: +//! +//! ```sh +//! cargo test -p gridfpv-engine --features live --test multi_main_live -- --ignored --nocapture +//! ``` +#![cfg(feature = "live")] + +mod common; + +use common::run_mock_heat; + +use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan}; +use gridfpv_engine::multi_main::MultiMain; +use gridfpv_engine::scoring::Placement; +use gridfpv_engine::scoring::{HeatResult, WinCondition, score_events}; +use gridfpv_events::{CompetitorRef, SourceTime}; +use gridfpv_projection::CompetitorKey; +use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios}; + +/// DISTINCT port for the multi-main e2e (heat e2e 5032, scoring 5033, single-elim 5037). +const PORT: u16 = 5044; + +/// A continuously-lapping stream for the main's intended winner. +fn busy_stream() -> String { + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + }) +} + +/// A drop-out stream: a couple of early laps then flat — far fewer laps than the busy one. +fn dnf_stream() -> String { + plan_csv(&scenarios::dnf(2, 6)) +} + +/// The seat node index behind a `node-{i}` competitor ref, if it has that shape. +fn node_index(key: &CompetitorKey) -> Option { + key.competitor.0.strip_prefix("node-")?.parse().ok() +} + +/// Rebuild a placement under the main's competitor `as_ref`, preserving position/laps. +fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { + Placement { + competitor: CompetitorKey { + adapter: place.competitor.adapter.clone(), + competitor: as_ref.clone(), + }, + position: place.position, + laps: place.laps, + metric: place.metric, + } +} + +/// Run one main against real RotorHazard and return its scored [`HeatResult`] expressed in +/// the main's own competitor refs. `winner` (seated on node 0, busy stream) is intended to +/// win; everyone else is a DNF node. Node placements are translated back by lineup position. +fn run_main_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { + let mut ordered: Vec<&CompetitorRef> = Vec::with_capacity(plan.lineup.len()); + ordered.push(winner); + for c in &plan.lineup { + if c != winner { + ordered.push(c); + } + } + + let scenario: Vec<(usize, String)> = ordered + .iter() + .enumerate() + .map(|(node, _)| { + let stream = if node == 0 { + busy_stream() + } else { + dnf_stream() + }; + (node, stream) + }) + .collect(); + + let log = run_mock_heat(PORT, &plan.heat.0, &scenario); + let race_start = SourceTime::from_micros(0); + let scored = score_events( + &log, + WinCondition::Timed { + window_micros: 10 * 60 * 1_000_000, + }, + race_start, + ); + + let mut places: Vec = Vec::new(); + let mut seen: Vec = vec![false; ordered.len()]; + for place in &scored.places { + if let Some(node) = node_index(&place.competitor) { + if node < ordered.len() { + seen[node] = true; + places.push(remap(place, ordered[node])); + } + } + } + let mut next_pos = places.len() as u32 + 1; + for (node, present) in seen.iter().enumerate() { + if !present { + places.push(Placement { + competitor: CompetitorKey { + adapter: scored + .places + .first() + .map(|p| p.competitor.adapter.clone()) + .unwrap_or_else(|| gridfpv_events::AdapterId("rotorhazard".into())), + competitor: ordered[node].clone(), + }, + position: next_pos, + laps: 0, + metric: gridfpv_engine::scoring::Metric::LastLapAt(None), + }); + next_pos += 1; + } + } + + CompletedHeat::new(plan.heat.0.clone(), HeatResult { places }) +} + +#[test] +#[ignore = "requires Docker (spins up dockerized RotorHazard and drives full heats)"] +fn two_tier_mains_run_over_real_heats_and_rank_a_above_b() { + // 6 qualifying-ranked pilots, main_size 3 → A main (1,2,3) and B main (4,5,6). + let field: Vec = ["1", "2", "3", "4", "5", "6"] + .iter() + .map(|n| CompetitorRef(n.to_string())) + .collect(); + let mut generator = MultiMain::new(field.clone(), 3); + + let a_field: Vec<&str> = vec!["1", "2", "3"]; + let b_field: Vec<&str> = vec!["4", "5", "6"]; + + let mut completed: Vec = Vec::new(); + let mut steps = 0; + while let GeneratorStep::Run(heats) = generator.next(&completed) { + steps += 1; + assert!( + steps < 4, + "tiered mains should converge in a single Run step" + ); + assert!(!heats.is_empty(), "a Run step must carry at least one heat"); + for plan in &heats { + // Top seed present in each main takes the busy stream (smallest numeric ref). + let winner = plan + .lineup + .iter() + .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX)) + .cloned() + .expect("non-empty lineup"); + eprintln!( + "multi-main e2e: heat {} lineup {:?} intended winner {}", + plan.heat.0, + plan.lineup.iter().map(|c| &c.0).collect::>(), + winner.0 + ); + completed.push(run_main_heat(plan, &winner)); + } + } + + // The format terminated; every pilot appears in the final ranking. + let ranking = generator.ranking(&completed); + assert_eq!(ranking.len(), 6, "every pilot should appear in the ranking"); + + // The defining property: the whole A main outranks the whole B main. The worst + // A-main position is strictly better (smaller) than the best B-main position. + let pos = |c: &str| { + ranking + .iter() + .find(|e| e.competitor.0 == c) + .unwrap_or_else(|| panic!("missing {c} in ranking")) + .position + }; + let worst_a = a_field.iter().map(|c| pos(c)).max().unwrap(); + let best_b = b_field.iter().map(|c| pos(c)).min().unwrap(); + assert!( + worst_a < best_b, + "every A-main finisher must rank above every B-main finisher \ + (worst A pos {worst_a} vs best B pos {best_b})" + ); + + eprintln!( + "multi-main e2e: final order {:?}", + ranking.iter().map(|e| &e.competitor.0).collect::>() + ); +} From 7a49df6985691e2f09bbb9d08df0017804bb65a8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 16:58:25 +0000 Subject: [PATCH 035/362] Add round-robin format generator Implement `RoundRobin`, a rounds-based `format::Generator` sibling to `timed_qual`: each round partitions the field into heats of `heat_size` and rotates the partition (cyclic rotation by the round index) so pilots meet a varied spread of opponents, then aggregates each pilot's results across all their heats into one tie-aware ranking. - `next` emits a round's worth of heats (`rr-r{n}-h{i}`) until `rounds` rounds are done, then `Complete`; pure function of completed history. - `ranking` aggregates by finishing-position points (default) or total laps, "more is better", deterministically tie-broken via `rank_by`. - Rotation by `r mod field_len` re-pairs pilots every consecutive round (heat_size >= 2); indivisible fields leave a short final heat; an empty field / heat_size 0 are handled. - `from_config` reads `rounds`/`heat_size`/`metric`; `register` under "round_robin". - 22 table tests + a mock-RH e2e (`round_robin_live`, port 5043). `cargo xtask ci` passes; the live e2e passes under Docker. Part of #13 (round-robin generator). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/round_robin.rs | 696 +++++++++++++++++++++++- crates/engine/tests/round_robin_live.rs | 203 +++++++ 2 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/round_robin_live.rs diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs index 13f30f6..3ffd284 100644 --- a/crates/engine/src/round_robin.rs +++ b/crates/engine/src/round_robin.rs @@ -1 +1,695 @@ -//! round_robin format generator — implements format::Generator (filled by the agent). +//! `round_robin` — a rounds-based [`Generator`] that **rotates** the field through a +//! fixed number of heats so pilots face a varied spread of opponents +//! (race-engine.html §3). +//! +//! # What round-robin is here +//! +//! Where [`crate::timed_qual`] flies the *whole* field together every round, a +//! round-robin **partitions** the field into heats of `heat_size` each round and +//! **rotates** the partition across `rounds` rounds, so a pilot meets a *different +//! mix* of opponents from one round to the next. A pilot's standing aggregates their +//! results across every heat they flew — by finishing-position **points** (default) or +//! by **total laps** — into one tie-aware ranking. +//! +//! This is the sibling of `timed_qual`: same "emit a round, score it, feed it back, +//! repeat until `rounds` are done" shape, but the per-round lineup is a *rotated +//! partition* rather than the whole field, and the aggregation **sums** across rounds +//! rather than taking a single best flight. +//! +//! # The rotation schedule (deterministic, balanced) +//! +//! For round `r` (0-based) the field is **cyclically rotated left** by `r` positions +//! and then sliced into consecutive heats of `heat_size`: +//! +//! ```text +//! offset(r) = r mod field_len +//! rotated = field[offset..] ++ field[..offset] +//! heats = rotated chunked into runs of `heat_size` +//! ``` +//! +//! Each round shifts the chunk boundaries by one pilot, so the partition genuinely +//! re-mixes from one round to the next: a partition repeats only once `r` has advanced +//! a full `heat_size` (the boundaries realign), so for any `heat_size ≥ 2` **every pair +//! of consecutive rounds re-pairs pilots** and a pilot meets a fresh spread of +//! opponents as the rounds progress. The schedule is a pure function of `(r, +//! field_len)` — no clock, no RNG — so it replays identically (RE §6). (With +//! `heat_size = 1` every pilot is alone in their own heat, so the partition is trivially +//! the same every round — there are no opponents to vary.) +//! +//! # Indivisible fields +//! +//! When `field_len` is not a multiple of `heat_size`, the chunking leaves a **short +//! final heat** carrying the remainder (e.g. 7 pilots at `heat_size` 3 → heats of 3, +//! 3, 1). The split is purely positional on the rotated order, so it is deterministic +//! and the short heat lands on different pilots each round as the rotation advances. A +//! `heat_size` of 0 is treated as 1 (every pilot in their own heat) so the format +//! always makes progress. +//! +//! # The aggregation metric (config-selected, points by default) +//! +//! Each heat is *scored* by the format's [`crate::scoring::WinCondition`] (the e2e +//! drives that); the cross-heat **aggregation** ranks pilots by a [`RrMetric`]: +//! +//! - [`RrMetric::Points`] (**default**) — finishing-position points, *lower position = +//! more points*. A pilot in a heat of `k` scores `k - position + 1` points for that +//! heat (the winner of a `k`-pilot heat gets `k`, last gets `1`); points sum across +//! all the pilot's heats and **more points = better**. The classic round-robin +//! standing: consistently finishing near the front wins. +//! - [`RrMetric::TotalLaps`] — the sum of laps banked across all the pilot's heats; +//! **more laps = better**. +//! +//! Either way the aggregate is ranked into a tie-aware order with [`rank_by`], with the +//! competitor ref as the final, total, deterministic tie-break (RE §6). +//! +//! [`rank_by`]: crate::format::rank_by +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use gridfpv_events::CompetitorRef; + +use crate::format::{ + CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry, + rank_by, +}; + +/// Which aggregate the cross-heat ranking ranks pilots by. See the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RrMetric { + /// Finishing-position points summed across heats (more = better). The default. + #[default] + Points, + /// Total laps banked across heats (more = better). + TotalLaps, +} + +impl RrMetric { + /// Parse a `metric` config value, defaulting to [`RrMetric::Points`] for an absent + /// or unrecognised value (the documented default). + fn parse(value: Option<&str>) -> Self { + match value { + Some("total-laps") | Some("laps") => RrMetric::TotalLaps, + // "points", anything else, or absent → the default points standing. + _ => RrMetric::Points, + } + } +} + +/// A round-robin generator: over `rounds` rounds the field is partitioned into heats of +/// `heat_size`, the partition **rotating** each round so pilots face a varied spread of +/// opponents, then each pilot's heats are aggregated into one ranking (RE §3). +/// +/// Construct with [`RoundRobin::new`] or, via the registry, [`from_config`] under the +/// name [`NAME`](Self::NAME). `next` emits one round's worth of heats at a time until +/// `rounds` rounds are completed, then [`GeneratorStep::Complete`]; `ranking` aggregates +/// across every heat under the configured [`RrMetric`]. +/// +/// [`from_config`]: RoundRobin::from_config +pub struct RoundRobin { + /// The field, in seed/draw order (any recorded outcome already applied). The base + /// order the rotation schedule rotates and the final ranking tie-break. + field: Vec, + /// How many rounds the field flies before the format completes. + rounds: usize, + /// How many pilots share a heat within a round (1 or more; 0 is treated as 1). + heat_size: usize, + /// Which aggregate the cross-heat ranking ranks by. + metric: RrMetric, +} + +impl RoundRobin { + /// The format name this registers under. + pub const NAME: &'static str = "round_robin"; + + /// The default number of rounds when `rounds` is not configured. + pub const DEFAULT_ROUNDS: usize = 3; + + /// The default heat size when `heat_size` is not configured. + pub const DEFAULT_HEAT_SIZE: usize = 4; + + /// Build over `field` for `rounds` rounds of heats of `heat_size`, aggregated by + /// `metric`. The field is taken in the given order (apply any + /// [`crate::format::SeedingOutcome`] before calling, as + /// [`from_config`](Self::from_config) does). + pub fn new( + field: Vec, + rounds: usize, + heat_size: usize, + metric: RrMetric, + ) -> Self { + Self { + field, + rounds, + // Never let heat_size be 0, or chunking would loop forever / make no + // progress; one pilot per heat is the sensible floor. + heat_size: heat_size.max(1), + metric, + } + } + + /// The registry constructor: applies the recorded `seeding` draw to `field`, reads + /// `rounds` (default [`DEFAULT_ROUNDS`](Self::DEFAULT_ROUNDS)), `heat_size` (default + /// [`DEFAULT_HEAT_SIZE`](Self::DEFAULT_HEAT_SIZE)) and the `metric` param (default + /// [`RrMetric::Points`]). + pub fn from_config(config: &FormatConfig) -> Box { + let field = config.seeding.apply(&config.field); + let rounds = config.param_usize("rounds", Self::DEFAULT_ROUNDS); + let heat_size = config.param_usize("heat_size", Self::DEFAULT_HEAT_SIZE); + let metric = RrMetric::parse(config.params.get("metric").map(String::as_str)); + Box::new(Self::new(field, rounds, heat_size, metric)) + } + + /// Register this format under [`NAME`](Self::NAME). + pub fn register(registry: &mut FormatRegistry) { + registry.register(Self::NAME, Self::from_config); + } + + /// Heat id for heat `i` (1-based) of round `n` (1-based) — `rr-r1-h1`, `rr-r1-h2`, + /// `rr-r2-h1`, … + fn heat_id(round: usize, heat: usize) -> String { + format!("rr-r{round}-h{heat}") + } + + /// The cyclic rotation offset for round `r` (0-based). See the module docs: rotating + /// by `r` shifts every chunk boundary by one pilot each round so the partition + /// re-mixes opponents from round to round instead of repeating. + fn offset(&self, round_index: usize) -> usize { + let len = self.field.len(); + if len == 0 { + return 0; + } + round_index % len + } + + /// The field rotated left by [`offset`](Self::offset) for round `r` (0-based). + fn rotated(&self, round_index: usize) -> Vec { + let len = self.field.len(); + if len == 0 { + return Vec::new(); + } + let offset = self.offset(round_index); + let mut out = Vec::with_capacity(len); + out.extend_from_slice(&self.field[offset..]); + out.extend_from_slice(&self.field[..offset]); + out + } + + /// The heat plans for round `n` (1-based): the field rotated for this round, sliced + /// into consecutive heats of `heat_size` (the last possibly short). + fn round_heats(&self, round: usize) -> Vec { + let rotated = self.rotated(round - 1); + rotated + .chunks(self.heat_size) + .enumerate() + .map(|(heat_index, chunk)| { + HeatPlan::new(Self::heat_id(round, heat_index + 1), chunk.to_vec()) + }) + .collect() + } + + /// How many heats one round emits (the same every round — the field partitioned). + fn heats_per_round(&self) -> usize { + let len = self.field.len(); + if len == 0 { + 0 + } else { + len.div_ceil(self.heat_size) + } + } +} + +impl Generator for RoundRobin { + fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { + // Each round emits `heats_per_round` heats; the completed count divided by that + // is the number of rounds fully run. Emit the next round while any remain, + // otherwise the format is complete. Pure function of (completed.len(), schedule) + // — no clock, no RNG. + let per_round = self.heats_per_round(); + if per_round == 0 { + // Empty field: nothing to ever run. + return GeneratorStep::Complete; + } + let rounds_run = completed.len() / per_round; + if rounds_run < self.rounds { + GeneratorStep::Run(self.round_heats(rounds_run + 1)) + } else { + GeneratorStep::Complete + } + } + + fn ranking(&self, completed: &[CompletedHeat]) -> Vec { + // Aggregate each competitor across every heat they flew under the configured + // metric. Seed every field member at 0 so a no-show still appears in the + // ranking. The accumulated score is "more is better", so we negate it into a + // smaller-is-better rank key for `rank_by`. + let mut totals: BTreeMap = BTreeMap::new(); + for competitor in &self.field { + totals.entry(competitor.clone()).or_insert(0); + } + + for heat in completed { + let heat_size = heat.result.places.len() as i64; + for place in &heat.result.places { + let gain = match self.metric { + // Finishing-position points: a heat of `k` awards `k` to the winner + // (position 1) down to `1` for last (position k). Tied pilots share a + // position and so are awarded the same points. + RrMetric::Points => (heat_size - place.position as i64 + 1).max(0), + // Total laps banked in this heat. + RrMetric::TotalLaps => place.laps as i64, + }; + *totals + .entry(place.competitor.competitor.clone()) + .or_insert(0) += gain; + } + } + + // Rank key: negate the accumulated score so more points / laps = smaller key = + // better; `rank_by` adds the competitor ref as the final, total tie-break. + let rows: Vec<(CompetitorRef, i64)> = totals + .into_iter() + .map(|(competitor, score)| (competitor, -score)) + .collect(); + rank_by(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scoring::{HeatResult, Metric, Placement}; + use gridfpv_events::AdapterId; + use gridfpv_projection::CompetitorKey; + use std::collections::HashSet; + + const ADAPTER: &str = "demo"; + + fn cref(name: &str) -> CompetitorRef { + CompetitorRef(name.into()) + } + + fn field(names: &[&str]) -> Vec { + names.iter().map(|n| cref(n)).collect() + } + + fn names(entries: &[RankEntry]) -> Vec { + entries.iter().map(|e| e.competitor.0.clone()).collect() + } + + /// Build a `HeatResult` from `(name, position, laps)` rows. + fn result(rows: &[(&str, u32, u32)]) -> HeatResult { + HeatResult { + places: rows + .iter() + .map(|(name, position, laps)| Placement { + competitor: CompetitorKey { + adapter: AdapterId(ADAPTER.into()), + competitor: cref(name), + }, + position: *position, + laps: *laps, + metric: Metric::LastLapAt(None), + }) + .collect(), + } + } + + /// The set of competitor names a heat plan lines up. + fn lineup_names(plan: &HeatPlan) -> Vec { + plan.lineup.iter().map(|c| c.0.clone()).collect() + } + + /// Extract the `Run` plans from a step, panicking if it completed. + fn run(step: GeneratorStep) -> Vec { + match step { + GeneratorStep::Run(plans) => plans, + GeneratorStep::Complete => panic!("expected Run, got Complete"), + } + } + + /// All unordered opponent pairs a pilot shares a heat with, across a round's heats. + fn pairs(plans: &[HeatPlan]) -> HashSet<(String, String)> { + let mut out = HashSet::new(); + for plan in plans { + let pilots = lineup_names(plan); + for i in 0..pilots.len() { + for j in (i + 1)..pilots.len() { + let (a, b) = (pilots[i].clone(), pilots[j].clone()); + out.insert(if a < b { (a, b) } else { (b, a) }); + } + } + } + out + } + + // --- next(): partitions and rotation ------------------------------------- + + #[test] + fn round_one_partitions_the_field_into_heats_of_heat_size() { + let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points); + let plans = run(g.next(&[])); + // 4 pilots / heat_size 2 → two heats. + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].heat.0, "rr-r1-h1"); + assert_eq!(plans[1].heat.0, "rr-r1-h2"); + assert_eq!(lineup_names(&plans[0]), vec!["A", "B"]); + assert_eq!(lineup_names(&plans[1]), vec!["C", "D"]); + } + + #[test] + fn every_round_covers_the_whole_field_exactly_once() { + let g = RoundRobin::new( + field(&["A", "B", "C", "D", "E", "F"]), + 4, + 2, + RrMetric::Points, + ); + for round in 1..=4 { + let plans = g.round_heats(round); + let mut seen: Vec = plans.iter().flat_map(lineup_names).collect(); + seen.sort(); + assert_eq!( + seen, + vec!["A", "B", "C", "D", "E", "F"], + "round {round} must cover the whole field exactly once" + ); + } + } + + #[test] + fn the_partition_rotates_so_pilots_meet_different_opponents() { + let g = RoundRobin::new( + field(&["A", "B", "C", "D", "E", "F"]), + 3, + 2, + RrMetric::Points, + ); + // Each consecutive round re-pairs pilots (the partition rotates by one pilot). + let p1 = pairs(&g.round_heats(1)); + let p2 = pairs(&g.round_heats(2)); + let p3 = pairs(&g.round_heats(3)); + assert_ne!(p1, p2, "round 2 must re-pair pilots vs round 1"); + assert_ne!(p2, p3, "round 3 must re-pair pilots vs round 2"); + } + + #[test] + fn rotation_re_pairs_a_specific_pilot_across_rounds() { + // A concrete opponent check: A's heat-mates change between round 1 and round 2. + let g = RoundRobin::new( + field(&["A", "B", "C", "D", "E", "F"]), + 2, + 3, + RrMetric::Points, + ); + let mates = |plans: &[HeatPlan]| -> Vec { + let heat = plans + .iter() + .find(|p| lineup_names(p).contains(&"A".to_string())) + .unwrap(); + let mut m: Vec = lineup_names(heat) + .into_iter() + .filter(|n| n != "A") + .collect(); + m.sort(); + m + }; + assert_ne!( + mates(&g.round_heats(1)), + mates(&g.round_heats(2)), + "A should meet a different set of opponents in round 2" + ); + } + + #[test] + fn indivisible_field_leaves_a_short_final_heat() { + // 7 pilots at heat_size 3 → heats of 3, 3, 1 (the remainder in a short heat). + let mut g = RoundRobin::new( + field(&["A", "B", "C", "D", "E", "F", "G"]), + 2, + 3, + RrMetric::Points, + ); + let plans = run(g.next(&[])); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0].lineup.len(), 3); + assert_eq!(plans[1].lineup.len(), 3); + assert_eq!(plans[2].lineup.len(), 1, "the remainder rides a short heat"); + } + + #[test] + fn heat_size_larger_than_field_is_one_heat() { + let mut g = RoundRobin::new(field(&["A", "B", "C"]), 1, 8, RrMetric::Points); + let plans = run(g.next(&[])); + assert_eq!(plans.len(), 1); + assert_eq!(lineup_names(&plans[0]), vec!["A", "B", "C"]); + } + + // --- next(): completion --------------------------------------------------- + + #[test] + fn completes_after_the_configured_rounds() { + // 4 pilots, heat_size 2 → 2 heats/round, 2 rounds → 4 heats total. + let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points); + let mut history: Vec = Vec::new(); + for round in 1..=2 { + let plans = run(g.next(&history)); + assert_eq!(plans.len(), 2, "round {round} emits two heats"); + for plan in plans { + history.push(CompletedHeat::new( + plan.heat.0.clone(), + result(&[(&plan.lineup[0].0, 1, 3), (&plan.lineup[1].0, 2, 2)]), + )); + } + } + // After both rounds (4 heats), the format completes. + assert_eq!(g.next(&history), GeneratorStep::Complete); + } + + #[test] + fn emits_round_two_after_round_one_completes() { + let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points); + // Round 1's two heats come back. + let r1 = run(g.next(&[])); + let history: Vec = r1 + .iter() + .map(|p| { + CompletedHeat::new( + p.heat.0.clone(), + result(&[(&p.lineup[0].0, 1, 3), (&p.lineup[1].0, 2, 2)]), + ) + }) + .collect(); + let r2 = run(g.next(&history)); + assert_eq!(r2[0].heat.0, "rr-r2-h1"); + assert_eq!(r2[1].heat.0, "rr-r2-h2"); + } + + #[test] + fn empty_field_completes_immediately() { + let mut g = RoundRobin::new(field(&[]), 3, 2, RrMetric::Points); + assert_eq!(g.next(&[]), GeneratorStep::Complete); + } + + // --- next(): determinism -------------------------------------------------- + + #[test] + fn next_is_deterministic_for_the_same_history() { + let mut g1 = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points); + let mut g2 = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points); + assert_eq!(g1.next(&[]), g2.next(&[])); + } + + // --- ranking(): points aggregation --------------------------------------- + + #[test] + fn ranking_by_points_rewards_winning_more_heats() { + // 4 pilots, heat_size 2, 2 rounds. A wins both its heats; D loses both. + // Points in a heat of 2: winner 2, loser 1. + let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points); + // Round 1: r1-h1 = A,B (A wins); r1-h2 = C,D (C wins). + // Round 2 (rotated): use whatever heats, but make A win again and D lose again. + let completed = vec![ + CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])), + // Round 2 pairings differ, but A still wins and D still loses. + CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 5), ("D", 2, 4)])), + CompletedHeat::new("rr-r2-h2", result(&[("C", 1, 5), ("B", 2, 4)])), + ]; + // Points: A = 2+2 = 4; C = 2+2 = 4; B = 1+1 = 2; D = 1+1 = 2. + // A and C tie (4), B and D tie (2). Tie-break is competitor ref. + let ranking = g.ranking(&completed); + assert_eq!(names(&ranking), vec!["A", "C", "B", "D"]); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[1].position, 1, "A and C tie on 4 points"); + assert_eq!(ranking[2].position, 3); + assert_eq!(ranking[3].position, 3, "B and D tie on 2 points"); + } + + #[test] + fn ranking_a_consistent_winner_tops_the_table() { + // A wins every heat outright → most points → first. + let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points); + let completed = vec![ + CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])), + CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 5), ("C", 2, 4)])), + CompletedHeat::new("rr-r2-h2", result(&[("B", 1, 5), ("D", 2, 4)])), + ]; + // A: 2+2=4; B: 1+2=3; C: 2+1=3; D: 1+1=2 → A, then B/C tie, then D. + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("A")); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[3].competitor, cref("D")); + assert_eq!(ranking[3].position, 4); + } + + #[test] + fn ranking_points_handle_heats_of_different_sizes() { + // A wins a heat of 3 (3 points); B wins a heat of 1 (1 point, alone). A ahead. + let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 1, 3, RrMetric::Points); + let completed = vec![ + CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("C", 2, 4), ("D", 3, 3)])), + CompletedHeat::new("rr-r1-h2", result(&[("B", 1, 6)])), + ]; + // Points: A=3, C=2, D=1, B=1. A first; B and D tie on 1. + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("A")); + assert_eq!(ranking[0].position, 1); + } + + #[test] + fn ranking_by_total_laps_sums_across_heats() { + let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::TotalLaps); + let completed = vec![ + CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 3)])), + CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 4), ("D", 2, 2)])), + CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 6), ("C", 2, 4)])), + CompletedHeat::new("rr-r2-h2", result(&[("B", 1, 5), ("D", 2, 1)])), + ]; + // Total laps: A=5+6=11; B=3+5=8; C=4+4=8; D=2+1=3. + // A first, B/C tie on 8, D last. + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("A")); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[1].position, 2); + assert_eq!(ranking[2].position, 2, "B and C tie on 8 laps"); + assert_eq!(ranking[3].competitor, cref("D")); + assert_eq!(ranking[3].position, 4); + } + + #[test] + fn ranking_before_any_heat_lists_the_whole_field_tied() { + // Provisional ranking with no history: every field member appears, all tied at 0. + let g = RoundRobin::new(field(&["B", "A", "C"]), 3, 2, RrMetric::Points); + let ranking = g.ranking(&[]); + assert_eq!(names(&ranking), vec!["A", "B", "C"]); + assert!(ranking.iter().all(|e| e.position == 1)); + } + + #[test] + fn ranking_is_deterministic_across_runs() { + let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points); + let completed = vec![ + CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])), + ]; + assert_eq!(g.ranking(&completed), g.ranking(&completed)); + } + + // --- full loop ------------------------------------------------------------ + + #[test] + fn full_two_round_loop_terminates_with_a_points_ranking() { + let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points); + let mut history: Vec = Vec::new(); + let mut rounds_seen = 0; + + loop { + let step = g.next(&history); + let plans = match step { + GeneratorStep::Run(plans) => plans, + GeneratorStep::Complete => break, + }; + rounds_seen += 1; + assert!(!plans.is_empty()); + for plan in plans { + // Front pilot of each heat wins it. + history.push(CompletedHeat::new( + plan.heat.0.clone(), + result(&[(&plan.lineup[0].0, 1, 4), (&plan.lineup[1].0, 2, 2)]), + )); + } + } + + assert_eq!(rounds_seen, 2, "exactly two rounds were emitted"); + // Idempotent terminal state. + assert_eq!(g.next(&history), GeneratorStep::Complete); + + let ranking = g.ranking(&history); + assert_eq!(ranking.len(), 4, "the final ranking covers the whole field"); + assert_eq!(ranking[0].position, 1); + for window in ranking.windows(2) { + assert!(window[0].position <= window[1].position); + } + } + + // --- registry ------------------------------------------------------------- + + #[test] + fn registry_builds_round_robin_from_config() { + let mut registry = FormatRegistry::new(); + RoundRobin::register(&mut registry); + assert_eq!(registry.names(), vec!["round_robin"]); + + let cfg = FormatConfig::new(field(&["A", "B", "C", "D"])) + .with_param("rounds", "2") + .with_param("heat_size", "2"); + let mut g = registry + .build(RoundRobin::NAME, &cfg) + .expect("round_robin is registered"); + // Round 1 partitions the field into two heats of two. + let plans = run(g.next(&[])); + assert_eq!(plans.len(), 2); + assert_eq!(lineup_names(&plans[0]), vec!["A", "B"]); + assert_eq!(lineup_names(&plans[1]), vec!["C", "D"]); + } + + #[test] + fn config_defaults_to_points_metric_and_default_sizes() { + // No params → DEFAULT_ROUNDS, DEFAULT_HEAT_SIZE, Points metric. + let cfg = FormatConfig::new(field(&["A", "B", "C", "D", "E"])); + let g = RoundRobin::from_config(&cfg); + // Ranking with no history lists the whole field (proves the field was taken). + assert_eq!(g.ranking(&[]).len(), 5); + } + + #[test] + fn config_seeding_draw_orders_the_partition() { + use crate::format::SeedingOutcome; + // A recorded draw reorders the field; round 1 partitions the drawn order. + let cfg = FormatConfig::new(field(&["A", "B", "C", "D"])) + .with_seeding(SeedingOutcome::drawn(field(&["C", "A", "D", "B"]))) + .with_param("rounds", "1") + .with_param("heat_size", "2"); + let mut g = RoundRobin::from_config(&cfg); + let plans = run(g.next(&[])); + assert_eq!(lineup_names(&plans[0]), vec!["C", "A"]); + assert_eq!(lineup_names(&plans[1]), vec!["D", "B"]); + } + + #[test] + fn config_total_laps_metric_parses() { + let cfg = FormatConfig::new(field(&["A", "B"])) + .with_param("metric", "total-laps") + .with_param("rounds", "1") + .with_param("heat_size", "2"); + let g = RoundRobin::from_config(&cfg); + let completed = vec![CompletedHeat::new( + "rr-r1-h1", + result(&[("B", 1, 9), ("A", 2, 3)]), + )]; + // By laps, B (9) beats A (3) even though both share the only heat. + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("B")); + } +} diff --git a/crates/engine/tests/round_robin_live.rs b/crates/engine/tests/round_robin_live.rs new file mode 100644 index 0000000..d978ae6 --- /dev/null +++ b/crates/engine/tests/round_robin_live.rs @@ -0,0 +1,203 @@ +//! Round-robin end-to-end test (#13) — drive the [`RoundRobin`] generator loop over +//! *real* mock-RH heats. +//! +//! This is the §5.1 "mock-RH e2e" for the round-robin format: rather than hand-write +//! `HeatResult`s (the exact table tests in `round_robin::tests` do that), it closes the +//! whole loop against a **real dockerized RotorHazard** — +//! +//! ```text +//! generator.next(history) -> Run([HeatPlan, ..]) (one round's partition of heats) +//! for each HeatPlan: run_mock_heat -> score_events(Timed) -> CompletedHeat +//! history += completed +//! ... repeat until next() -> Complete (after `ROUNDS` rounds) ... +//! assert: the loop terminated and the final points ranking covers the field +//! ``` +//! +//! Each heat is scored under [`WinCondition::Timed`] (most laps in a window); the +//! generator aggregates finishing-position points across every heat a pilot flew. +//! +//! Structural / tolerant, exactly like the other format e2es: the mock interface reads +//! its CSV continuously, so lap *timing* is not controllable. We assert the loop +//! **terminates** with a `Complete` after exactly `ROUNDS` rounds, that we ran one real +//! heat per scheduled plan, and that the final ranking is non-empty, ordered, and tops +//! out with the continuously-lapping node — never exact µs or laps. With a two-node +//! field and `heat_size` 2 each round is a single heat of both nodes; node-0 laps +//! throughout the live window while node-1 DNFs early, so node-0 wins every heat and +//! tops the points table. +//! +//! For RH the canonical pass `at` is **ms since race start**, so the timed clock's +//! origin is zero (`SourceTime::from_micros(0)`). +//! +//! Local-only class (needs Docker). DISTINCT port 5043. Run: +//! +//! ```sh +//! cargo test -p gridfpv-engine --features live --test round_robin_live -- --ignored --nocapture +//! ``` +#![cfg(feature = "live")] + +mod common; + +use common::run_mock_heat; + +use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry}; +use gridfpv_engine::round_robin::{RoundRobin, RrMetric}; +use gridfpv_engine::scoring::{WinCondition, score_events}; +use gridfpv_events::{CompetitorRef, SourceTime}; +use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios}; + +/// DISTINCT port for the round-robin e2e — never clashing with the other engine or +/// adapter live tests (which occupy 5030–5040). +const PORT: u16 = 5043; + +/// RH pass `at` is ms-since-race-start, so the timed clock starts at zero. +fn race_start() -> SourceTime { + SourceTime::from_micros(0) +} + +/// The win condition each heat is scored under: most laps in a generous window. +fn condition() -> WinCondition { + WinCondition::Timed { + window_micros: 60_000_000, + } +} + +/// The two-node scenario every heat flies: a continuous fast lapper (node-0) vs a DNF +/// node (node-1). node-0 laps throughout the live window so it banks the most laps and +/// wins each heat outright. +fn scenario() -> Vec<(usize, String)> { + vec![ + ( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + }), + ), + (1usize, plan_csv(&scenarios::dnf(2, 6))), + ] +} + +#[test] +#[ignore = "requires Docker (spins up dockerized RotorHazard and drives the round-robin loop over real heats)"] +fn round_robin_loop_terminates_with_a_points_ranking() { + // The field the round-robin runs over: the two seat refs the harness produces. + let field: Vec = vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ]; + + // Two rounds, heat_size 2 — with two nodes each round is a single heat of both, so + // the loop runs two real heats total while still proving multi-round aggregation. + const ROUNDS: usize = 2; + const HEAT_SIZE: usize = 2; + + let mut generator = RoundRobin::new(field.clone(), ROUNDS, HEAT_SIZE, RrMetric::Points); + + // The growing history of scored heats — the generator's only input about the past. + let mut history: Vec = Vec::new(); + let mut heats_run = 0usize; + let mut rounds_seen = 0usize; + + // Drive rounds until the generator declares the format complete. + loop { + let step = generator.next(&history); + let plans: Vec = match step { + GeneratorStep::Run(plans) => plans, + GeneratorStep::Complete => break, + }; + assert!( + !plans.is_empty(), + "a Run step must carry at least one heat plan" + ); + rounds_seen += 1; + + for plan in plans { + // Each heat covers a slice of the field; the two-node field makes it both. + assert!( + !plan.lineup.is_empty(), + "heat {} has an empty lineup", + plan.heat.0 + ); + assert!( + plan.heat.0.starts_with("rr-r"), + "heat id should follow the rr-r{{n}}-h{{i}} scheme, got {}", + plan.heat.0 + ); + + let log = run_mock_heat(PORT, &plan.heat.0, &scenario()); + let result = score_events(&log, condition(), race_start()); + + assert!( + !result.places.is_empty(), + "heat {} produced no scored competitors", + plan.heat.0 + ); + + eprintln!( + "round-robin e2e: heat {} scored {} competitor(s)", + plan.heat.0, + result.places.len() + ); + + history.push(CompletedHeat { + heat: plan.heat.clone(), + result, + }); + heats_run += 1; + } + } + + // The loop terminated via Complete after exactly the configured rounds. + assert_eq!( + rounds_seen, ROUNDS, + "the loop should run exactly ROUNDS rounds" + ); + assert_eq!( + heats_run, ROUNDS, + "two nodes at heat_size 2 → one heat per round → ROUNDS heats total" + ); + + // Calling next again still completes (idempotent terminal state). + assert_eq!( + generator.next(&history), + GeneratorStep::Complete, + "the generator stays Complete once all rounds are flown" + ); + + // The final points ranking covers the field, is in non-decreasing position order, + // and starts at position 1 — structural assertions only. + let ranking: Vec = generator.ranking(&history); + assert_eq!( + ranking.len(), + field.len(), + "the final ranking should cover the whole field" + ); + assert_eq!( + ranking[0].position, 1, + "the ranking must start at position 1" + ); + for window in ranking.windows(2) { + assert!( + window[0].position <= window[1].position, + "ranking must be in non-decreasing position order" + ); + } + + // node-0 laps continuously and wins every heat; node-1 DNFs, so node-0 banks the + // most points and tops the table. (Structural: which node wins is fixed by the + // scenario, not by timing.) + assert_eq!( + ranking[0].competitor, + CompetitorRef("node-0".into()), + "the continuously-lapping node should top the points ranking" + ); + + println!( + "round-robin e2e: ran {heats_run} heats over {ROUNDS} rounds; final ranking = {:?}", + ranking + .iter() + .map(|e| (e.competitor.0.as_str(), e.position)) + .collect::>() + ); +} From 7eefe94569d729b05ef00f036dd5eb7710ab0780 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 16:59:18 +0000 Subject: [PATCH 036/362] Add double-elimination format generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement `DoubleElim`, a head-to-head double-elimination bracket generator over a seeded field, as the natural extension of `SingleElim`. A loss in the winners bracket (WB) drops a competitor into the losers bracket (LB); a second loss eliminates. The LB alternates minor rounds (LB survivors pair off) and major rounds (LB survivors meet the newest WB droppers). The undefeated WB champion meets the once-defeated LB champion in the grand final. The replay-based `next`/`ranking` core walks both brackets in lock-step from the completed history — pure, no clock or RNG; a recorded `SeedingOutcome` and `bracket_pairs` drive seeding. Grand-final bracket reset is implemented and config-gated (`bracket_reset`, default on): if the LB champion wins grand-final heat 1 both have one loss, so a deterministic decider (`de-gf-reset`) is emitted; an undefeated WB champion win needs none. Byes (odd fields) advance the trailing competitor deterministically. Registered under "double_elim". Tests: table tests for 3/4/8-seed brackets to a champion, loser-drops-to-LB, second-loss-eliminates, lower-seed comeback through losers, byes, no-reset config, determinism, and registry. Mock-RH e2e (`double_elim_live`, live feature, ignored, port 5042) drives a 4-seed bracket over real dockerized RotorHazard heats to a single champion. `cargo xtask ci` green; `double_elim_live` passes under Docker. Part of #13 (double-elim generator). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/double_elim.rs | 944 +++++++++++++++++++++++- crates/engine/tests/double_elim_live.rs | 211 ++++++ 2 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/double_elim_live.rs diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs index 6eabbb8..16e1b72 100644 --- a/crates/engine/src/double_elim.rs +++ b/crates/engine/src/double_elim.rs @@ -1 +1,943 @@ -//! double_elim format generator — implements format::Generator (filled by the agent). +//! Double-elimination bracket generator (#13) — a [`Generator`] that seeds a field +//! into a **double**-elimination bracket: a loss drops a competitor from the winners +//! bracket into the losers bracket, and a second loss eliminates them. The last two +//! standing (the undefeated winners champion and the once-defeated losers champion) +//! meet in the grand final. +//! +//! # The format (race-engine.html §3, §5) +//! +//! Double elimination is the natural extension of [`crate::single_elim`]: the same +//! fixed-but-state-driven shape (RE §3) — the whole bracket is determined by the +//! seeded field, yet every round is derived from the results so far. `next` reads the +//! completed heats, takes each head-to-head heat's **winner** (the top of the +//! result), and lays out the next round across **both** brackets. It reads no clock or +//! RNG: any seeding draw is resolved once and injected as a [`SeedingOutcome`] at +//! construction (RE §6), so the bracket replays identically. +//! +//! This generator runs **head-to-head (2-pilot) heats** for correctness: every match +//! is a clean win/loss, which is what makes "drop the loser, eliminate on a second +//! loss" unambiguous. Larger-heat variants (advance the top half, drop the bottom +//! half into losers) are a future extension and are intentionally out of scope here. +//! +//! # Bracket bookkeeping +//! +//! 1. **Seed the field.** The config carries the field in seed order (best first); a +//! recorded [`SeedingOutcome`] is applied first (identity if none). The first +//! winners round pairs strong-vs-weak via [`bracket_pairs`] (1 v N, 2 v N-1, …), +//! the standard seeding that keeps top seeds apart. +//! 2. **Winners bracket (WB).** A plain single-elimination knockout. The **winner** of +//! each WB heat advances within the WB; the **loser** drops into the losers bracket +//! (LB). The WB runs until one undefeated competitor remains — the WB champion. +//! 3. **Losers bracket (LB).** Fed by the WB's droppers. It alternates two kinds of +//! rounds, the standard double-elim layout: +//! - a **minor** round pairs the current LB survivors against each other, then +//! - a **major** round pairs those survivors against the newest batch of WB +//! droppers. +//! +//! The loser of *any* LB heat is eliminated (their second loss). The LB runs until +//! one once-defeated competitor remains — the LB champion. +//! 4. **Grand final.** WB champion (0 losses) vs LB champion (1 loss). +//! +//! # Grand-final bracket reset (documented choice) +//! +//! A purist double-elimination grand final is *true*: because the LB champion has one +//! loss and the WB champion has none, if the LB champion wins the first grand-final +//! heat both competitors then have one loss, so a **second** decider ("the reset") is +//! played. If the WB champion wins the first heat, the LB champion is eliminated on +//! their second loss and no reset is needed. +//! +//! This generator implements the **bracket reset**, gated by the `bracket_reset` +//! config param (default **on**, `"1"`; set `"0"` for a single-heat "winner-takes-it" +//! grand final). The choice is fully deterministic: the reset heat is emitted **iff** +//! the LB champion won the first grand-final heat *and* reset is enabled — a pure +//! function of the completed history, never a clock or a flag mutated mid-run. +//! +//! # Byes (odd / short fields) +//! +//! When a round's competitor list is odd, the **trailing** competitor (the middle seed +//! in the first WB round, by [`bracket_pairs`]; the last survivor otherwise) takes a +//! **bye** and advances for free, deterministically. The same rule applies in the LB, +//! so an odd field still converges. +//! +//! # Ranking +//! +//! [`Generator::ranking`] is the bracket placement: the **champion** first, the +//! **grand-final loser** second, then everyone else ordered by **how far they got** — +//! a competitor eliminated in a later round (WB or LB) outranks one knocked out +//! earlier. Within an elimination round, finishers keep the in-heat order, with the +//! competitor ref as the final deterministic tie-break. Provisional before the bracket +//! completes, final after. +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use gridfpv_events::CompetitorRef; + +use crate::format::{ + CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry, + bracket_pairs, rank_by, result_ranking, +}; +use crate::scoring::HeatResult; + +/// A double-elimination bracket over a seeded field (head-to-head heats). +/// +/// Constructed with the seeded field (draw order already applied) and whether the +/// grand final allows a [bracket reset](self#grand-final-bracket-reset-documented-choice); +/// `next` drives the rounds and `ranking` reports the bracket placement. See the module +/// docs for the seeding / bye / WB-LB / grand-final rules. +pub struct DoubleElim { + /// The field in seed/draw order (the recorded [`SeedingOutcome`] already applied). + field: Vec, + /// Whether a losers-champion win in grand-final heat 1 forces a decider (reset). + bracket_reset: bool, +} + +impl DoubleElim { + /// The format name this registers under. + pub const NAME: &'static str = "double_elim"; + + /// Build over a `field` in seed order, with the grand-final bracket reset enabled + /// or disabled. + pub fn new(field: Vec, bracket_reset: bool) -> Self { + Self { + field, + bracket_reset, + } + } + + /// The registry constructor: applies the recorded `seeding` draw to `field` and + /// reads the optional `bracket_reset` param (default on — any value other than + /// `"0"`/`"false"` keeps it enabled). + pub fn from_config(config: &FormatConfig) -> Box { + let field = config.seeding.apply(&config.field); + let bracket_reset = !matches!( + config.params.get("bracket_reset").map(String::as_str), + Some("0") | Some("false") | Some("off") | Some("no") + ); + Box::new(Self::new(field, bracket_reset)) + } + + /// Register this format under [`NAME`](Self::NAME). + pub fn register(registry: &mut FormatRegistry) { + registry.register(Self::NAME, Self::from_config); + } + + // --- Heat-id scheme ----------------------------------------------------- + // + // Winners: `de-w{round}-h{index}` (round, heat index both 0-based-ish: round + // 1-based, heat 0-based, mirroring single_elim's `se-r{round}-h{index}`). + // Losers: `de-l{round}-h{index}`. + // Grand final: `de-gf` (first heat) and `de-gf-reset` (the decider). + + fn winners_id(round: usize, index: usize) -> String { + format!("de-w{round}-h{index}") + } + + fn losers_id(round: usize, index: usize) -> String { + format!("de-l{round}-h{index}") + } + + const GRAND_FINAL: &'static str = "de-gf"; + const GRAND_FINAL_RESET: &'static str = "de-gf-reset"; + + /// Pair `order` into head-to-head heats with the given id builder, returning the + /// heat plans **and** the bye (a single trailing competitor advances for free). + /// + /// Deterministic: chunks are taken left-to-right; a trailing single is the bye. + fn pair_round( + round: usize, + order: &[CompetitorRef], + id: impl Fn(usize, usize) -> String, + ) -> (Vec, Option) { + let mut heats = Vec::new(); + let mut bye = None; + let mut index = 0usize; + let mut chunks = order.chunks(2); + for chunk in &mut chunks { + if chunk.len() == 2 { + heats.push(HeatPlan::new(id(round, index), chunk.to_vec())); + index += 1; + } else { + bye = Some(chunk[0].clone()); + } + } + (heats, bye) + } + + /// Replay the whole bracket from the completed history. This is the pure core both + /// [`next`](Generator::next) and [`ranking`](Generator::ranking) build on: it walks + /// the winners bracket and losers bracket round by round in lock-step, matching + /// each round's emitted heats against `completed` by heat id, advancing winners and + /// dropping losers, and only progressing past a round once every one of its heats + /// has come back. + fn replay(&self, completed: &[CompletedHeat]) -> Replay { + let by_id: BTreeMap<&str, &HeatResult> = completed + .iter() + .map(|c| (c.heat.0.as_str(), &c.result)) + .collect(); + + // The winner / loser of a completed heat, looked up by id. `None` if the heat + // has not come back yet. + let outcome = |heat_id: &str| -> Option<(CompetitorRef, CompetitorRef)> { + let result = by_id.get(heat_id)?; + let ranking = result_ranking(result); + // Head-to-head: top is the winner, next is the loser. + let winner = ranking.first()?.competitor.clone(); + let loser = ranking.get(1)?.competitor.clone(); + Some((winner, loser)) + }; + + let mut pending: Vec = Vec::new(); + // Per round (earliest first) the competitors that round eliminated, in heat + // order — the second-loss losers, used to band the ranking. + let mut eliminated_by_round: Vec> = Vec::new(); + + // --- Winners bracket: advance like a single-elim, collecting droppers. --- + // + // `wb_drops[r]` is the batch of competitors who lost in winners round `r` + // (1-based), in heat order — the feed into the losers bracket. + let mut wb_round = 1usize; + let mut wb_order = bracket_pairs(&self.field); + let mut wb_drops: Vec> = vec![Vec::new()]; // index 0 unused + let wb_champion: Option = loop { + if wb_order.len() <= 1 { + break wb_order.first().cloned(); + } + let (heats, bye) = Self::pair_round(wb_round, &wb_order, Self::winners_id); + let mut next_order = Vec::new(); + let mut drops = Vec::new(); + let mut complete = true; + for heat in &heats { + match outcome(&heat.heat.0) { + Some((winner, loser)) => { + next_order.push(winner); + drops.push(loser); + } + None => { + complete = false; + pending.push(heat.clone()); + } + } + } + if !complete { + // This WB round is still running; stop advancing the WB. We still let + // the LB run on the droppers already produced by earlier WB rounds. + wb_drops.push(Vec::new()); + break None; + } + if let Some(b) = bye { + next_order.push(b); + } + wb_drops.push(drops); + wb_order = next_order; + wb_round += 1; + }; + let wb_settled = wb_champion.is_some(); + + // --- Losers bracket: alternate minor (LB-vs-LB) and major (LB-vs-WB-drop). --- + // + // LB round numbering is its own sequence (1-based). The standard layout: + // L1 (minor): pair the round-1 WB droppers against each other. + // L2 (major): the L1 survivors meet the round-2 WB droppers. + // L3 (minor): pair the L2 survivors against each other. + // L4 (major): the L3 survivors meet the round-3 WB droppers. + // … until one LB survivor remains (the LB champion). + // + // We only lay out an LB round once its inputs exist: a major round needs that + // WB round's droppers to have been produced (which requires the WB to have run + // that far). If the inputs are not ready, the LB waits — exactly mirroring how + // a real schedule cannot run a losers heat before its feeders finish. + let mut lb_round = 1usize; + let mut lb_survivors: Vec = Vec::new(); + // Which WB drop-batch the next *major* round will consume (round 2 first). + let mut next_drop_round = 2usize; + // The LB heat-id round counter is independent of the minor/major distinction; + // it just increments per LB round we lay out. + let lb_champion: Option = loop { + // Inject the first batch of droppers as the LB seed before round 1. + if lb_round == 1 { + lb_survivors = wb_drops.get(1).cloned().unwrap_or_default(); + } + + let is_major = lb_round % 2 == 0; + if is_major { + // Major round: bring in the next WB drop batch. It must be available — + // i.e. that WB round must have fully completed. If not, the LB stalls + // here until the WB produces it. + let drop_ready = wb_settled || next_drop_round < wb_drops.len(); + let incoming = wb_drops.get(next_drop_round).cloned(); + match incoming { + Some(drops) if drop_ready => { + // Interleave survivors with incoming droppers for the pairing. + // Standard practice cross-seeds them; we keep it deterministic + // and simple: survivors first (in their order), then droppers. + lb_survivors.extend(drops); + next_drop_round += 1; + } + _ => { + // The feeding WB round has not finished; the LB cannot run this + // major round yet. Stop here (no LB champion settled). + break None; + } + } + } + + if lb_survivors.len() <= 1 { + // One (or zero) LB competitor left. If the WB is settled this is the LB + // champion; otherwise the LB is just waiting on more droppers. + if wb_settled && next_drop_round >= wb_drops.len() { + break lb_survivors.first().cloned(); + } + // More droppers are still coming (a future major round). Advance the + // round counter so the next major round pulls them in. + if !is_major { + lb_round += 1; + continue; + } + break None; + } + + let (heats, bye) = Self::pair_round(lb_round, &lb_survivors, Self::losers_id); + let mut next_survivors = Vec::new(); + let mut eliminated = Vec::new(); + let mut complete = true; + for heat in &heats { + match outcome(&heat.heat.0) { + Some((winner, loser)) => { + next_survivors.push(winner); + eliminated.push(loser); + } + None => { + complete = false; + pending.push(heat.clone()); + } + } + } + if !complete { + break None; + } + if let Some(b) = bye { + next_survivors.push(b); + } + if !eliminated.is_empty() { + eliminated_by_round.push(eliminated); + } + lb_survivors = next_survivors; + lb_round += 1; + }; + + // --- Grand final ---------------------------------------------------- + // + // Only meaningful once BOTH champions are known. The WB champion has 0 losses, + // the LB champion 1. First heat `de-gf`; an optional reset `de-gf-reset`. + let mut champion: Option = None; + let mut runner_up: Option = None; + if let (Some(wb_champ), Some(lb_champ)) = (wb_champion.clone(), lb_champion.clone()) { + match outcome(Self::GRAND_FINAL) { + None => { + // Grand final not yet run: emit it. WB champ seeded first. + pending.push(HeatPlan::new( + Self::GRAND_FINAL, + vec![wb_champ.clone(), lb_champ.clone()], + )); + } + Some((gf_winner, gf_loser)) => { + if gf_winner == wb_champ { + // WB champ won → LB champ takes their 2nd loss. Done, no reset. + champion = Some(wb_champ.clone()); + runner_up = Some(gf_loser); + } else { + // LB champ won heat 1 → both now have one loss. + if self.bracket_reset { + // Reset: a decider settles it. + match outcome(Self::GRAND_FINAL_RESET) { + None => { + pending.push(HeatPlan::new( + Self::GRAND_FINAL_RESET, + vec![wb_champ.clone(), lb_champ.clone()], + )); + } + Some((reset_winner, reset_loser)) => { + champion = Some(reset_winner); + runner_up = Some(reset_loser); + } + } + } else { + // No reset: the single grand final stands. + champion = Some(gf_winner); + runner_up = Some(gf_loser); + } + } + } + } + } + + Replay { + pending, + champion, + runner_up, + wb_champion, + lb_champion, + lb_survivors, + eliminated_by_round, + } + } +} + +/// The outcome of replaying the completed history (see [`DoubleElim::replay`]). +struct Replay { + /// Heats awaiting a result, across both brackets and the grand final. Empty once + /// the bracket is complete. + pending: Vec, + /// The overall champion, once the grand final has resolved. + champion: Option, + /// The grand-final loser (overall runner-up), once it has resolved. + runner_up: Option, + /// The winners-bracket champion, once the WB has settled (0 losses). + wb_champion: Option, + /// The losers-bracket champion, once the LB has settled (1 loss). + lb_champion: Option, + /// The competitors still alive in the losers bracket mid-run (for provisional + /// ranking); collapses to the LB champion once it settles. + lb_survivors: Vec, + /// Per LB round (earliest first), the competitors that round eliminated (their + /// second loss), in heat order. + eliminated_by_round: Vec>, +} + +impl Generator for DoubleElim { + fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { + let replay = self.replay(completed); + if replay.pending.is_empty() && replay.champion.is_some() { + GeneratorStep::Complete + } else if replay.pending.is_empty() { + // Defensive: no pending heats but no champion yet. With a well-formed + // history this does not happen (a stalled bracket always has a pending + // heat or a champion); treat it as complete so the loop terminates. + GeneratorStep::Complete + } else { + GeneratorStep::Run(replay.pending) + } + } + + fn ranking(&self, completed: &[CompletedHeat]) -> Vec { + let replay = self.replay(completed); + + // Bands, best first: + // 0: champion (once known) — alone at the top. + // 1: grand-final loser (runner-up). + // then: still-alive competitors not yet placed (WB/LB champions mid-run, + // LB survivors), in a "furthest along" band, + // then: each LB elimination round, latest first (further = higher), + // finally: everyone who never appeared (e.g. a never-run field) by seed. + let mut rows: Vec<(CompetitorRef, (u32, u32))> = Vec::new(); + let mut placed: Vec = Vec::new(); + let mut band = 0u32; + + let push = |rows: &mut Vec<(CompetitorRef, (u32, u32))>, + placed: &mut Vec, + competitor: &CompetitorRef, + band: u32, + within: u32| { + if !placed.contains(competitor) { + placed.push(competitor.clone()); + rows.push((competitor.clone(), (band, within))); + } + }; + + if let Some(champ) = &replay.champion { + push(&mut rows, &mut placed, champ, band, 0); + } + band += 1; + if let Some(ru) = &replay.runner_up { + push(&mut rows, &mut placed, ru, band, 0); + } + band += 1; + + // Still-alive-but-unplaced competitors (mid-run): the WB champion waiting on + // the LB, the LB champion waiting on the grand final, the LB survivors. They + // outrank everyone already eliminated. + if let Some(wb) = &replay.wb_champion { + push(&mut rows, &mut placed, wb, band, 0); + } + if let Some(lb) = &replay.lb_champion { + push(&mut rows, &mut placed, lb, band, 1); + } + for (i, survivor) in replay.lb_survivors.iter().enumerate() { + push(&mut rows, &mut placed, survivor, band, 2 + i as u32); + } + band += 1; + + // Eliminated in the losers bracket, latest round first (advanced furthest). + for eliminated in replay.eliminated_by_round.iter().rev() { + for (i, competitor) in eliminated.iter().enumerate() { + push(&mut rows, &mut placed, competitor, band, i as u32); + } + band += 1; + } + + // Any field member never seen anywhere (e.g. before any heat / tiny field): + // keep them in seed order behind everyone, so the ranking is always total. + for (i, competitor) in self.field.iter().enumerate() { + push(&mut rows, &mut placed, competitor, band, i as u32); + } + + rank_by(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::SeedingOutcome; + use crate::scoring::{Metric, Placement}; + use gridfpv_events::AdapterId; + use gridfpv_projection::CompetitorKey; + + const ADAPTER: &str = "demo"; + + fn cref(name: &str) -> CompetitorRef { + CompetitorRef(name.into()) + } + + fn field(names: &[&str]) -> Vec { + names.iter().map(|n| cref(n)).collect() + } + + /// Build a `HeatResult` from `(name, position, laps)` rows. + fn result(rows: &[(&str, u32, u32)]) -> HeatResult { + HeatResult { + places: rows + .iter() + .map(|(name, position, laps)| Placement { + competitor: CompetitorKey { + adapter: AdapterId(ADAPTER.into()), + competitor: cref(name), + }, + position: *position, + laps: *laps, + metric: Metric::LastLapAt(None), + }) + .collect(), + } + } + + /// A head-to-head result where `winner` beats `loser`. + fn h2h(winner: &str, loser: &str) -> HeatResult { + result(&[(winner, 1, 5), (loser, 2, 3)]) + } + + fn names(entries: &[RankEntry]) -> Vec { + entries.iter().map(|e| e.competitor.0.clone()).collect() + } + + /// The (heat-id, lineup) pairs of a `Run` step (panics if `Complete`). + fn lineups(step: &GeneratorStep) -> Vec<(String, Vec)> { + match step { + GeneratorStep::Run(heats) => heats + .iter() + .map(|h| { + ( + h.heat.0.clone(), + h.lineup.iter().map(|c| c.0.clone()).collect(), + ) + }) + .collect(), + GeneratorStep::Complete => panic!("expected Run, got Complete"), + } + } + + /// Just the heat ids of a `Run` step. + fn heat_ids(step: &GeneratorStep) -> Vec { + lineups(step).into_iter().map(|(id, _)| id).collect() + } + + /// Drive a generator to completion, resolving every emitted heat with `pick` + /// (given a heat id + its lineup, return the winner). Returns the full completed + /// history. Panics if it does not converge within a generous round budget. + fn drive( + g: &mut DoubleElim, + pick: impl Fn(&str, &[CompetitorRef]) -> CompetitorRef, + ) -> Vec { + let mut completed: Vec = Vec::new(); + let mut rounds = 0; + while let GeneratorStep::Run(heats) = g.next(&completed) { + rounds += 1; + assert!(rounds < 32, "bracket should converge well within 32 rounds"); + assert!(!heats.is_empty(), "a Run step must carry at least one heat"); + for plan in &heats { + let winner = pick(&plan.heat.0, &plan.lineup); + let loser = plan + .lineup + .iter() + .find(|c| **c != winner) + .cloned() + .expect("head-to-head heat has a loser"); + completed.push(CompletedHeat::new( + plan.heat.0.clone(), + h2h(&winner.0, &loser.0), + )); + } + } + completed + } + + /// A `pick` where the lower numeric seed (smaller ref) always wins — i.e. a + /// perfectly chalk bracket. + fn chalk(_id: &str, lineup: &[CompetitorRef]) -> CompetitorRef { + lineup + .iter() + .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX)) + .cloned() + .expect("non-empty lineup") + } + + // --- Round-1 layout ----------------------------------------------------- + + #[test] + fn winners_round_one_pairs_strong_vs_weak_head_to_head() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + // bracket order 1,4,2,3 → WB heats (1 v 4), (2 v 3). No LB/GF yet. + assert_eq!( + lineups(&g.next(&[])), + vec![ + ("de-w1-h0".to_string(), vec!["1".into(), "4".into()]), + ("de-w1-h1".to_string(), vec!["2".into(), "3".into()]), + ] + ); + } + + // --- Loser drops to losers bracket -------------------------------------- + + #[test] + fn winners_loser_drops_into_the_losers_bracket() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + let _ = g.next(&[]); + // WB round 1: 1 beats 4, 2 beats 3. Droppers: 4, 3. + let completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + // Next emits the WB final (1 v 2) AND the LB round-1 minor (4 v 3). + let ids = heat_ids(&g.next(&completed)); + assert!(ids.contains(&"de-w2-h0".to_string()), "WB final emitted"); + assert!( + ids.contains(&"de-l1-h0".to_string()), + "LB round-1 minor emitted from the WB droppers" + ); + // The LB heat pairs the two WB-round-1 losers. + let lb = lineups(&g.next(&completed)) + .into_iter() + .find(|(id, _)| id == "de-l1-h0") + .unwrap(); + assert_eq!(lb.1, vec!["4".to_string(), "3".to_string()]); + } + + // --- A second loss eliminates ------------------------------------------- + + #[test] + fn a_second_loss_eliminates_in_the_losers_bracket() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + // WB r1: 1>4, 2>3. WB final & LB minor open. + let mut completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + let _ = g.next(&completed); + // WB final: 1 beats 2 → 2 drops to LB. LB minor: 4 beats 3 → 3 is eliminated + // (its 2nd loss). + completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2"))); + completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3"))); + // Now the LB major pairs the LB survivor (4) with the WB-final dropper (2). + let ids = heat_ids(&g.next(&completed)); + assert!( + ids.contains(&"de-l2-h0".to_string()), + "LB major round emitted, got {ids:?}" + ); + let lb_major = lineups(&g.next(&completed)) + .into_iter() + .find(|(id, _)| id == "de-l2-h0") + .unwrap(); + // Survivor 4 vs WB-final loser 2. + assert_eq!(lb_major.1, vec!["4".to_string(), "2".to_string()]); + } + + // --- A once-beaten lower seed can still win via the losers bracket ------- + + #[test] + fn lower_seed_loses_once_then_wins_through_losers_to_champion() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + // Seed 2 will lose in the WB but storm back through the LB and win it all. + // WB r1: 1>4, 2>3. + let mut completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + let _ = g.next(&completed); + // WB final: 1 beats 2 (2's first loss → drops to LB). LB minor: 4 beats 3. + completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2"))); + completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3"))); + let _ = g.next(&completed); + // LB major: 2 beats 4 (4 eliminated) → 2 is the LB champion. + completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4"))); + // Grand final: WB champ 1 vs LB champ 2. + let gf = lineups(&g.next(&completed)); + assert_eq!( + gf, + vec![("de-gf".to_string(), vec!["1".into(), "2".into()])] + ); + // 2 wins the grand final heat 1 → both have one loss → reset decider. + completed.push(CompletedHeat::new("de-gf", h2h("2", "1"))); + let reset = lineups(&g.next(&completed)); + assert_eq!( + reset, + vec![("de-gf-reset".to_string(), vec!["1".into(), "2".into()])] + ); + // 2 wins the reset → 2 is champion despite an early WB loss. + completed.push(CompletedHeat::new("de-gf-reset", h2h("2", "1"))); + assert_eq!(g.next(&completed), GeneratorStep::Complete); + + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("2")); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[1].competitor, cref("1")); + assert_eq!(ranking[1].position, 2); + } + + // --- Grand final: WB champ wins heat 1, no reset ------------------------ + + #[test] + fn grand_final_wb_champ_win_needs_no_reset() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + let completed = drive(&mut g, chalk); + // Chalk: seed 1 wins every heat. No reset heat should ever be emitted. + assert!( + !completed.iter().any(|c| c.heat.0 == "de-gf-reset"), + "WB champ winning GF heat 1 needs no reset" + ); + assert!( + completed.iter().any(|c| c.heat.0 == "de-gf"), + "a grand final was played" + ); + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("1")); + assert_eq!(ranking[0].position, 1); + } + + // --- bracket_reset = false: single grand final -------------------------- + + #[test] + fn no_reset_config_plays_a_single_grand_final() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), false); + // WB r1. + let mut completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + let _ = g.next(&completed); + completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2"))); + completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3"))); + let _ = g.next(&completed); + completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4"))); + let _ = g.next(&completed); + // LB champ 2 wins the GF heat — with reset OFF that is the title, no decider. + completed.push(CompletedHeat::new("de-gf", h2h("2", "1"))); + assert_eq!(g.next(&completed), GeneratorStep::Complete); + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("2")); + assert_eq!(ranking[1].competitor, cref("1")); + } + + // --- Byes (odd field) --------------------------------------------------- + + #[test] + fn odd_field_gives_the_middle_seed_a_winners_bye() { + // 3 seeds: bracket order 1,3,2 → WB heat (1 v 3), bye for 2. + let mut g = DoubleElim::new(field(&["1", "2", "3"]), true); + assert_eq!( + lineups(&g.next(&[])), + vec![("de-w1-h0".to_string(), vec!["1".into(), "3".into()])] + ); + // 1 beats 3. WB final: 1 v 2 (2 had the bye). 3 dropped alone into the LB — + // with a single LB competitor there is no LB heat yet (it waits for more). + let completed = vec![CompletedHeat::new("de-w1-h0", h2h("1", "3"))]; + let ids = heat_ids(&g.next(&completed)); + assert!(ids.contains(&"de-w2-h0".to_string())); + let wb_final = lineups(&g.next(&completed)) + .into_iter() + .find(|(id, _)| id == "de-w2-h0") + .unwrap(); + assert_eq!(wb_final.1, vec!["1".to_string(), "2".to_string()]); + } + + #[test] + fn three_seed_bracket_runs_to_a_champion() { + let mut g = DoubleElim::new(field(&["1", "2", "3"]), true); + let completed = drive(&mut g, chalk); + let ranking = g.ranking(&completed); + assert_eq!(ranking.len(), 3, "every seed appears in the ranking"); + assert_eq!(ranking[0].competitor, cref("1")); + assert_eq!(ranking[0].position, 1); + assert_eq!( + ranking.iter().filter(|e| e.position == 1).count(), + 1, + "exactly one champion" + ); + } + + // --- Full small brackets to a champion ---------------------------------- + + #[test] + fn full_four_seed_chalk_bracket_runs_to_top_seed() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + let completed = drive(&mut g, chalk); + let ranking = g.ranking(&completed); + assert_eq!(ranking.len(), 4); + assert_eq!(ranking[0].competitor, cref("1")); + assert_eq!(ranking[0].position, 1); + assert_eq!( + ranking.iter().filter(|e| e.position == 1).count(), + 1, + "exactly one champion" + ); + // The runner-up is the grand-final loser; everyone appears exactly once. + let mut seen: Vec = names(&ranking); + seen.sort(); + assert_eq!(seen, vec!["1", "2", "3", "4"]); + } + + #[test] + fn full_eight_seed_chalk_bracket_runs_to_top_seed() { + let mut g = DoubleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), true); + let completed = drive(&mut g, chalk); + let ranking = g.ranking(&completed); + assert_eq!(ranking.len(), 8, "every seed appears once"); + assert_eq!(ranking[0].competitor, cref("1")); + assert_eq!(ranking[0].position, 1); + assert_eq!( + ranking.iter().filter(|e| e.position == 1).count(), + 1, + "exactly one champion" + ); + let mut seen: Vec = names(&ranking); + seen.sort(); + assert_eq!(seen, vec!["1", "2", "3", "4", "5", "6", "7", "8"]); + } + + #[test] + fn eight_seed_upset_champion_comes_through_losers() { + // Seed 1 wins the WB until the grand final, but seed 2 (who lost to 1 in the + // WB) comes back through the LB and beats 1 twice in the GF (reset) to win. + let mut g = DoubleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), true); + // pick: seed 1 wins everything EXCEPT the two grand-final heats, which seed 2 + // wins; otherwise lower seed wins. This makes 2 the comeback champion. + let pick = |id: &str, lineup: &[CompetitorRef]| -> CompetitorRef { + if id == "de-gf" || id == "de-gf-reset" { + // Whoever is seed "2" in this heat wins; both GF heats are 1 v 2. + if lineup.contains(&cref("2")) { + return cref("2"); + } + } + chalk(id, lineup) + }; + let completed = drive(&mut g, pick); + let ranking = g.ranking(&completed); + assert_eq!(ranking[0].competitor, cref("2"), "comeback champion"); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[1].competitor, cref("1"), "GF loser is runner-up"); + assert_eq!(ranking[1].position, 2); + } + + // --- Determinism -------------------------------------------------------- + + #[test] + fn next_is_deterministic_for_the_same_history() { + let mut g1 = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + let mut g2 = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + assert_eq!(g1.next(&[]), g2.next(&[])); + let completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + assert_eq!(g1.next(&completed), g2.next(&completed)); + } + + #[test] + fn seeding_draw_reorders_the_bracket_deterministically() { + let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])) + .with_seeding(SeedingOutcome::drawn(field(&["3", "1", "4", "2"]))); + let mut g1 = DoubleElim::from_config(&cfg); + let mut g2 = DoubleElim::from_config(&cfg); + let s1 = g1.next(&[]); + assert_eq!(s1, g2.next(&[])); + // Drawn order 3,1,4,2 → bracket order 3,2,1,4 → WB heats (3 v 2), (1 v 4). + assert_eq!( + lineups(&s1), + vec![ + ("de-w1-h0".to_string(), vec!["3".into(), "2".into()]), + ("de-w1-h1".to_string(), vec!["1".into(), "4".into()]), + ] + ); + } + + // --- Completion edge case ----------------------------------------------- + + #[test] + fn single_competitor_field_completes_immediately() { + let mut g = DoubleElim::new(field(&["1"]), true); + assert_eq!(g.next(&[]), GeneratorStep::Complete); + assert_eq!(names(&g.ranking(&[])), vec!["1"]); + } + + // --- Provisional ranking ------------------------------------------------ + + #[test] + fn provisional_ranking_before_any_heat_is_seed_order() { + let g = DoubleElim::new(field(&["1", "2", "3", "4"]), true); + // Before any heat the field is all "still alive"; ranking falls back to seed + // order so it is total and stable. + assert_eq!(names(&g.ranking(&[])), vec!["1", "2", "3", "4"]); + } + + // --- Registry ----------------------------------------------------------- + + #[test] + fn registry_builds_double_elim() { + let mut registry = FormatRegistry::new(); + DoubleElim::register(&mut registry); + assert_eq!(registry.names(), vec!["double_elim"]); + + let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])); + let mut g = registry + .build(DoubleElim::NAME, &cfg) + .expect("double_elim is registered"); + assert_eq!( + lineups(&g.next(&[])), + vec![ + ("de-w1-h0".to_string(), vec!["1".into(), "4".into()]), + ("de-w1-h1".to_string(), vec!["2".into(), "3".into()]), + ] + ); + } + + #[test] + fn registry_reads_bracket_reset_param_off() { + let mut registry = FormatRegistry::new(); + DoubleElim::register(&mut registry); + let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])).with_param("bracket_reset", "0"); + let mut g = registry.build(DoubleElim::NAME, &cfg).unwrap(); + // Drive with the LB champ winning the GF; with reset off, no decider is played. + let mut completed = vec![ + CompletedHeat::new("de-w1-h0", h2h("1", "4")), + CompletedHeat::new("de-w1-h1", h2h("2", "3")), + ]; + let _ = g.next(&completed); + completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2"))); + completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3"))); + let _ = g.next(&completed); + completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4"))); + let _ = g.next(&completed); + completed.push(CompletedHeat::new("de-gf", h2h("2", "1"))); + assert_eq!(g.next(&completed), GeneratorStep::Complete); + } +} diff --git a/crates/engine/tests/double_elim_live.rs b/crates/engine/tests/double_elim_live.rs new file mode 100644 index 0000000..047369c --- /dev/null +++ b/crates/engine/tests/double_elim_live.rs @@ -0,0 +1,211 @@ +//! Double-elimination end-to-end test (#13) — a real bracket over real mock-RH heats. +//! +//! Drives a small double-elimination bracket (4 seeds) where every heat — winners +//! bracket, losers bracket, and grand final — is a **real** dockerized RotorHazard heat +//! run through the shared [`common::run_mock_heat`] harness. Each round the +//! [`DoubleElim`] generator emits the [`HeatPlan`]s; for each plan we map its two +//! competitors onto two RotorHazard nodes — the **intended winner** gets a +//! continuously-lapping `node_csv` stream, the opponent a `dnf` plan that drops out +//! early — run the heat, score it with [`score_events`], translate the node placements +//! back onto the bracket's competitor refs, and feed the [`CompletedHeat`] back into the +//! generator. We assert the bracket advances through both brackets and a single champion +//! emerges (the top seed, given the busy stream in every heat it flies). +//! +//! As with the scoring / single-elim e2e the mock reads its CSV continuously (lap timing +//! is not controllable), so this is **structural**: we rely only on "the busier node +//! out-laps the DNF node", never on exact lap times. The harness guarantees at least one +//! crossing per heat and the busy node supplies plenty. +//! +//! For RH the canonical pass `at` is **ms since race start**, so the timed clock starts +//! at zero (`race_start = SourceTime::from_micros(0)`). +//! +//! Local-only class (needs Docker). DISTINCT port 5042 (heat e2e 5032, scoring 5033, +//! single-elim 5037). Run: +//! +//! ```sh +//! cargo test -p gridfpv-engine --features live --test double_elim_live -- --ignored --nocapture +//! ``` +#![cfg(feature = "live")] + +mod common; + +use common::run_mock_heat; + +use gridfpv_engine::double_elim::DoubleElim; +use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan}; +use gridfpv_engine::scoring::Placement; +use gridfpv_engine::scoring::{HeatResult, WinCondition, score_events}; +use gridfpv_events::{CompetitorRef, SourceTime}; +use gridfpv_projection::CompetitorKey; +use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios}; + +/// DISTINCT port for the double-elim e2e (heat e2e 5032, scoring 5033, single-elim 5037, +/// adapters 5030/5031). +const PORT: u16 = 5042; + +/// A continuously-lapping stream for the heat's intended winner. +fn busy_stream() -> String { + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + }) +} + +/// A drop-out stream for the heat's intended loser: a couple of early laps then flat. +fn dnf_stream() -> String { + plan_csv(&scenarios::dnf(2, 6)) +} + +/// Run one bracket heat against real RotorHazard and return its scored [`HeatResult`] +/// **expressed in the bracket's own competitor refs** (same remapping shape as the +/// single-elim e2e: intended winner on node 0 with the busy stream, others DNF). +fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { + let mut ordered: Vec<&CompetitorRef> = Vec::with_capacity(plan.lineup.len()); + ordered.push(winner); + for c in &plan.lineup { + if c != winner { + ordered.push(c); + } + } + + let scenario: Vec<(usize, String)> = ordered + .iter() + .enumerate() + .map(|(node, _)| { + let stream = if node == 0 { + busy_stream() + } else { + dnf_stream() + }; + (node, stream) + }) + .collect(); + + let log = run_mock_heat(PORT, &plan.heat.0, &scenario); + let race_start = SourceTime::from_micros(0); + let scored = score_events( + &log, + WinCondition::Timed { + window_micros: 10 * 60 * 1_000_000, + }, + race_start, + ); + + // Translate node-seat placements back onto the bracket's competitor refs by lineup + // position; nodes that produced no live-window passes are parked behind the rest. + let mut places: Vec = Vec::new(); + let mut seen: Vec = vec![false; ordered.len()]; + for place in &scored.places { + if let Some(node) = node_index(&place.competitor) { + if node < ordered.len() { + seen[node] = true; + places.push(remap(place, ordered[node])); + } + } + } + let mut next_pos = places.len() as u32 + 1; + for (node, present) in seen.iter().enumerate() { + if !present { + places.push(Placement { + competitor: CompetitorKey { + adapter: scored + .places + .first() + .map(|p| p.competitor.adapter.clone()) + .unwrap_or_else(|| gridfpv_events::AdapterId("rotorhazard".into())), + competitor: ordered[node].clone(), + }, + position: next_pos, + laps: 0, + metric: gridfpv_engine::scoring::Metric::LastLapAt(None), + }); + next_pos += 1; + } + } + + CompletedHeat::new(plan.heat.0.clone(), HeatResult { places }) +} + +/// The seat node index behind a `node-{i}` competitor ref, if it has that shape. +fn node_index(key: &CompetitorKey) -> Option { + key.competitor.0.strip_prefix("node-")?.parse().ok() +} + +/// Rebuild a placement under the bracket competitor `as_ref`, preserving position/laps. +fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { + Placement { + competitor: CompetitorKey { + adapter: place.competitor.adapter.clone(), + competitor: as_ref.clone(), + }, + position: place.position, + laps: place.laps, + metric: place.metric, + } +} + +#[test] +#[ignore = "requires Docker (spins up dockerized RotorHazard and drives full heats)"] +fn four_seed_double_elim_runs_to_a_single_champion_over_real_heats() { + let field: Vec = ["1", "2", "3", "4"] + .iter() + .map(|n| CompetitorRef(n.to_string())) + .collect(); + // Head-to-head double elimination. The top seed is the intended winner of every + // heat it flies (winners, and the grand final), so it should emerge as champion + // without ever needing a bracket reset. + let intended_winner = CompetitorRef("1".into()); + let mut generator = DoubleElim::new(field, true); + + let mut completed: Vec = Vec::new(); + let mut rounds = 0; + while let GeneratorStep::Run(heats) = generator.next(&completed) { + rounds += 1; + assert!(rounds < 16, "bracket should converge well within 16 rounds"); + assert!(!heats.is_empty(), "a Run step must carry at least one heat"); + for plan in &heats { + // The intended winner wins any heat it is in; otherwise the highest seed + // present (smallest numeric ref) takes the busy stream and wins. + let winner = if plan.lineup.contains(&intended_winner) { + intended_winner.clone() + } else { + plan.lineup + .iter() + .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX)) + .cloned() + .expect("non-empty lineup") + }; + eprintln!( + "double-elim e2e: heat {} lineup {:?} intended winner {}", + plan.heat.0, + plan.lineup.iter().map(|c| &c.0).collect::>(), + winner.0 + ); + completed.push(run_bracket_heat(plan, &winner)); + } + } + + // The bracket completed: exactly one champion at the top of the final ranking. + let ranking = generator.ranking(&completed); + assert_eq!(ranking.len(), 4, "every seed should appear in the ranking"); + assert_eq!(ranking[0].position, 1, "there is a single champion"); + assert_eq!( + ranking[0].competitor, intended_winner, + "the seed given the busy stream in every heat wins the bracket" + ); + assert_eq!( + ranking.iter().filter(|e| e.position == 1).count(), + 1, + "exactly one competitor holds first place" + ); + // The top seed never loses, so no bracket-reset heat should have been played. + assert!( + !completed.iter().any(|c| c.heat.0 == "de-gf-reset"), + "an undefeated WB champion needs no reset" + ); + eprintln!( + "double-elim e2e: champion {} after {} rounds", + ranking[0].competitor.0, rounds + ); +} From cf3bcc11f12baa65519f973ca5ae12b858d1fb82 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 17:57:24 +0000 Subject: [PATCH 037/362] Wire the new generator e2es into cargo xtask live (#68, #69, #70) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- xtask/src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b02ef96..7dea902 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -171,6 +171,10 @@ fn live() -> bool { let zippyq_live = target("gridfpv-engine", "zippyq_live", true); let multiclass_live = target("gridfpv-engine", "multiclass_live", true); let full_event_live = target("gridfpv-engine", "full_event_live", true); + // Additional format generators (#68 double-elim, #69 round-robin, #70 multi-main). + let double_elim_live = target("gridfpv-engine", "double_elim_live", true); + let round_robin_live = target("gridfpv-engine", "round_robin_live", true); + let multi_main_live = target("gridfpv-engine", "multi_main_live", true); // The protocol server's mock-RH e2e: full event → server log → protocol client (#47). let server_e2e = target("gridfpv-server", "full_event_live", true); ws && live_rh @@ -184,6 +188,9 @@ fn live() -> bool { && zippyq_live && multiclass_live && full_event_live + && double_elim_live + && round_robin_live + && multi_main_live && server_e2e } From 7515ecf21aceecb12bffe1128c34548663d0023f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 18:10:19 +0000 Subject: [PATCH 038/362] Apply penalties and heat-void in scoring (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Event::PenaltyApplied` (Disqualify / TimeAdded) and `Event::HeatVoided` were appended by the control path but never folded into a `HeatResult`, so a DQ, time penalty, or voided heat left the score unchanged. Apply them in the engine scorer. Scoring entry point: `score_with_adjudications(events, condition, race_start)` distils a heat's log into `Adjudications` (accumulated per-competitor TimeAdded, the DQ set, the voided flag) and folds them on top of the pure on-track ranking. `score_events` now delegates to it, and `event::score_marshaled` applies the same adjudications on the corrected pass stream so penalties and marshaling compose. Per-penalty behavior: - Disqualify: the competitor is ranked after every non-disqualified one (DQ flag is the primary sort key) and flagged, regardless of on-track result; their laps/metric stay visible. - TimeAdded: accumulates per competitor and worsens the deciding time in the rank key — the Timed tie-break time, the FirstToLaps reach time, the BestLap duration, the BestConsecutive sum. The on-track `metric` is left unchanged for display. - HeatVoided: nullifies the heat by flagging `HeatResult.voided`; the places are still scored so the standing remains visible. All deterministic — pure folds, no clock or RNG. Additive fields: `Placement.disqualified: bool` and `HeatResult.voided: bool`, both `#[serde(default, skip_serializing_if = ..)]` (so they render as optional in TS and clean results are unchanged on the wire), with a `Default` path. Regenerated `bindings/Placement.ts` and `bindings/HeatResult.ts`. Ripple: the new fields broke every `Placement` / `HeatResult` struct-literal. Updated the format generators' table-test fixtures (single_elim, double_elim, round_robin, multi_main, zippyq, timed_qual, format), schedule, event, and the `*_live` integration tests (single_elim_live, double_elim_live, multi_main_live, full_event_live) — all via `..Default::default()` so future additions don't ripple again. No generator logic changed. Tests: exact table tests in scoring for DQ-drops-leader-to-last, two-DQ, TimeAdded reordering a best-lap and a timed tie-break, TimeAdded accumulation, HeatVoided flagging, clean-log equivalence, and a penalty+marshaling compose case. `cargo xtask ci` green (incl. gen drift); engine clippy + `--features live` build/clippy clean. Part of #13 (single-race penalties/heat-void). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/HeatResult.ts | 9 +- bindings/Placement.ts | 11 +- crates/engine/src/double_elim.rs | 2 + crates/engine/src/event.rs | 19 +- crates/engine/src/format.rs | 2 + crates/engine/src/multi_main.rs | 2 + crates/engine/src/round_robin.rs | 2 + crates/engine/src/schedule.rs | 5 +- crates/engine/src/scoring.rs | 519 +++++++++++++++++++++--- crates/engine/src/single_elim.rs | 2 + crates/engine/src/timed_qual.rs | 4 + crates/engine/src/zippyq.rs | 2 + crates/engine/tests/double_elim_live.rs | 10 +- crates/engine/tests/full_event_live.rs | 7 +- crates/engine/tests/multi_main_live.rs | 10 +- crates/engine/tests/single_elim_live.rs | 10 +- 16 files changed, 553 insertions(+), 63 deletions(-) diff --git a/bindings/HeatResult.ts b/bindings/HeatResult.ts index 1811675..02b99d1 100644 --- a/bindings/HeatResult.ts +++ b/bindings/HeatResult.ts @@ -12,4 +12,11 @@ export type HeatResult = { /** * Placements in finishing order (ties adjacent, sharing a position). */ -places: Array, }; +places: Array, +/** + * Whether the whole heat was **voided** by an adjudication + * ([`gridfpv_events::Event::HeatVoided`]). A voided result is nullified: its + * `places` are still scored (so the on-track standing is visible) but the heat does + * not count. Defaults to `false` and is omitted from the wire when false. + */ +voided?: boolean, }; diff --git a/bindings/Placement.ts b/bindings/Placement.ts index 32cf523..36131b8 100644 --- a/bindings/Placement.ts +++ b/bindings/Placement.ts @@ -28,4 +28,13 @@ laps: number, /** * The condition-specific deciding metric for this competitor. */ -metric: Metric, }; +metric: Metric, +/** + * Whether this competitor was **disqualified** by an adjudication + * ([`gridfpv_events::Penalty::Disqualify`] via + * [`gridfpv_events::Event::PenaltyApplied`]). A disqualified competitor is ranked + * **after every non-disqualified competitor**, regardless of their on-track result + * (see [`score_with_adjudications`]). Defaults to `false` and is omitted from the + * wire when false, so clean results carry no extra bytes. + */ +disqualified?: boolean, }; diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs index 16e1b72..81ed31a 100644 --- a/crates/engine/src/double_elim.rs +++ b/crates/engine/src/double_elim.rs @@ -517,8 +517,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 8340534..2028181 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -47,7 +47,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry, advance_top_n}; -use crate::scoring::{HeatResult, WinCondition, score}; +use crate::scoring::{HeatResult, WinCondition, apply_adjudications}; /// Turn one planned heat into its scored result. The single injected dependency the /// event driver needs: it owns *how* a heat is run (replay a fixture log, drive real @@ -168,8 +168,14 @@ pub fn run_event( /// with [`gridfpv_projection::lap_list_marshaled`] by construction (both fold via the /// single source of truth). /// +/// On top of the marshaling fold, this also applies the heat's **adjudications** +/// ([`gridfpv_events::Event::PenaltyApplied`] / [`gridfpv_events::Event::HeatVoided`], #13): +/// a `Disqualify` sinks a competitor below the field (flagging it), a `TimeAdded` worsens +/// their deciding time, and a `HeatVoided` flags the whole result voided — so a full event +/// run reflects penalties and heat-voids, not just lap corrections. +/// /// `race_start` is the shared race clock for [`WinCondition::Timed`] (ignored by the -/// qualifying / first-to-N conditions), matching [`score`]. +/// qualifying / first-to-N conditions), matching [`crate::scoring::score`]. pub fn score_marshaled( events: &[Event], condition: WinCondition, @@ -180,7 +186,10 @@ pub fn score_marshaled( // corrected lap-gate passes it returns. The scorer re-groups/re-orders by competitor. let corrected = gridfpv_projection::corrected_passes(events.iter().enumerate().map(|(i, e)| (i as u64, e))); - score(&corrected, condition, race_start) + // Penalties / heat-void are a *separate* fold from the marshaling corrections above: + // apply them on the corrected pass stream so an adjudicated, marshaled heat reflects + // both (#13). A log with no penalties scores exactly as before. + apply_adjudications(&corrected, condition, race_start, events) } #[cfg(test)] @@ -309,8 +318,10 @@ mod tests { position: (i as u32) + 1, laps: 0, metric: Metric::BestLapMicros(*micros), + ..Default::default() }) .collect(), + ..Default::default() } } @@ -328,8 +339,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index e76436c..a7e147c 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -681,8 +681,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs index 0ff33ba..07e2a28 100644 --- a/crates/engine/src/multi_main.rs +++ b/crates/engine/src/multi_main.rs @@ -214,8 +214,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs index 3ffd284..dd0d31c 100644 --- a/crates/engine/src/round_robin.rs +++ b/crates/engine/src/round_robin.rs @@ -309,8 +309,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/schedule.rs b/crates/engine/src/schedule.rs index 3877cf9..f54b565 100644 --- a/crates/engine/src/schedule.rs +++ b/crates/engine/src/schedule.rs @@ -431,7 +431,10 @@ mod tests { /// A trivial empty [`HeatResult`] for the scheduler tests, which only need *a* /// result to feed back — the scheduler never inspects its contents. fn empty_result() -> HeatResult { - HeatResult { places: Vec::new() } + HeatResult { + places: Vec::new(), + ..Default::default() + } } // --- Frequency allocation ----------------------------------------------- diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs index 29b9724..77dd06b 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -39,7 +39,9 @@ //! about the function distinguishes a finished heat from an in-progress one. #![forbid(unsafe_code)] -use gridfpv_events::{Event, Pass, SourceTime}; +use std::collections::{BTreeMap, BTreeSet}; + +use gridfpv_events::{CompetitorRef, Event, Pass, Penalty, SourceTime}; use gridfpv_projection::CompetitorKey; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -102,6 +104,41 @@ pub struct Placement { pub laps: u32, /// The condition-specific deciding metric for this competitor. pub metric: Metric, + /// Whether this competitor was **disqualified** by an adjudication + /// ([`gridfpv_events::Penalty::Disqualify`] via + /// [`gridfpv_events::Event::PenaltyApplied`]). A disqualified competitor is ranked + /// **after every non-disqualified competitor**, regardless of their on-track result + /// (see [`score_with_adjudications`]). Defaults to `false` and is omitted from the + /// wire when false, so clean results carry no extra bytes. + #[serde(default, skip_serializing_if = "is_false")] + pub disqualified: bool, +} + +/// serde `skip_serializing_if` predicate: omit additive `bool` flags when false so a +/// clean result serialises exactly as it did before these fields existed. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(b: &bool) -> bool { + !*b +} + +impl Default for Placement { + /// An empty placeholder placement. Exists so constructors (chiefly test fixtures) + /// can spread `..Default::default()` and only set the fields they care about, which + /// keeps additive [`Placement`] fields from rippling into every struct-literal again + /// (a later field defaults rather than breaking the build). `CompetitorKey` has no + /// `Default` of its own, so this supplies an empty one; callers always overwrite it. + fn default() -> Self { + Placement { + competitor: CompetitorKey { + adapter: gridfpv_events::AdapterId(String::new()), + competitor: CompetitorRef(String::new()), + }, + position: 0, + laps: 0, + metric: Metric::LastLapAt(None), + disqualified: false, + } + } } /// The condition-specific value a [`Placement`] was ranked on, kept for display and @@ -127,11 +164,17 @@ pub enum Metric { /// Ties share a `position` (see [`Placement::position`]). The order within a tie /// group is still deterministic — competitors are ordered by [`CompetitorKey`] as /// the final, total tie-break — but their `position` numbers are equal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub struct HeatResult { /// Placements in finishing order (ties adjacent, sharing a position). pub places: Vec, + /// Whether the whole heat was **voided** by an adjudication + /// ([`gridfpv_events::Event::HeatVoided`]). A voided result is nullified: its + /// `places` are still scored (so the on-track standing is visible) but the heat does + /// not count. Defaults to `false` and is omitted from the wire when false. + #[serde(default, skip_serializing_if = "is_false")] + pub voided: bool, } /// A single completed lap, with both its absolute completion time and its duration. @@ -201,19 +244,96 @@ impl Run { /// a position. Called on a partial pass list this is the **provisional / live /// ranking** (see the module docs). pub fn score(passes: &[Pass], condition: WinCondition, race_start: SourceTime) -> HeatResult { - let runs = Run::group(passes); - match condition { - WinCondition::Timed { window_micros } => score_timed(runs, race_start, window_micros), - WinCondition::FirstToLaps { n } => score_first_to_laps(runs, n), - WinCondition::BestLap => score_best_lap(runs), - WinCondition::BestConsecutive { n } => score_best_consecutive(runs, n), + score_inner(passes, condition, race_start, &Adjudications::default()) +} + +/// The adjudications a heat's [`Event::PenaltyApplied`] / [`Event::HeatVoided`] log +/// distils to, applied on top of the pure on-track scoring (race-engine.html §7.1, #13). +/// +/// Built by [`Adjudications::collect`] from a heat's events; pure data, so the same log +/// always yields the same adjudications and the scored result replays identically (no +/// clock or RNG). Penalties are keyed by [`CompetitorRef`] because that is what the +/// penalty events carry; a heat's events are all one heat, so the ref is unambiguous. +#[derive(Debug, Clone, Default)] +struct Adjudications { + /// Per-competitor microseconds added to their deciding time, **accumulated** across + /// every [`Penalty::TimeAdded`] for that competitor (multiple penalties stack). + time_added: BTreeMap, + /// Competitors disqualified by a [`Penalty::Disqualify`] — ranked after everyone not + /// disqualified and flagged [`Placement::disqualified`]. + disqualified: BTreeSet, + /// Whether the whole heat was voided ([`Event::HeatVoided`]). + voided: bool, +} + +impl Adjudications { + /// Distil a heat's event log into its adjudications. Ignores everything that is not a + /// [`Event::PenaltyApplied`] / [`Event::HeatVoided`]; `TimeAdded` penalties accumulate, + /// any `Disqualify` disqualifies, any `HeatVoided` voids. Deterministic — pure fold. + fn collect(events: &[Event]) -> Self { + let mut adj = Adjudications::default(); + for event in events { + match event { + Event::PenaltyApplied { + competitor, + penalty, + .. + } => match penalty { + Penalty::Disqualify => { + adj.disqualified.insert(competitor.clone()); + } + Penalty::TimeAdded { micros } => { + *adj.time_added.entry(competitor.clone()).or_default() += *micros; + } + }, + Event::HeatVoided { .. } => adj.voided = true, + _ => {} + } + } + adj + } + + /// Microseconds to add to `competitor`'s deciding time (0 if none). + fn added(&self, competitor: &CompetitorRef) -> i64 { + self.time_added.get(competitor).copied().unwrap_or_default() + } + + /// Whether `competitor` was disqualified. + fn is_dq(&self, competitor: &CompetitorRef) -> bool { + self.disqualified.contains(competitor) } } -/// Convenience wrapper over a canonical [`Event`] log: filters to lap-gate -/// [`Pass`]es and scores them, so callers holding a heat's event log (e.g. from the -/// mock-RH harness) need not pre-filter. -pub fn score_events( +/// Score `passes` under `condition`, then apply `adj`'s adjudications: [`Penalty::TimeAdded`] +/// worsens the deciding time used to rank a competitor, [`Penalty::Disqualify`] sinks a +/// competitor below every non-disqualified one (flagging [`Placement::disqualified`]), and +/// [`Event::HeatVoided`] flags the whole [`HeatResult`] voided. +fn score_inner( + passes: &[Pass], + condition: WinCondition, + race_start: SourceTime, + adj: &Adjudications, +) -> HeatResult { + let runs = Run::group(passes); + let mut result = match condition { + WinCondition::Timed { window_micros } => score_timed(runs, race_start, window_micros, adj), + WinCondition::FirstToLaps { n } => score_first_to_laps(runs, n, adj), + WinCondition::BestLap => score_best_lap(runs, adj), + WinCondition::BestConsecutive { n } => score_best_consecutive(runs, n, adj), + }; + result.voided = adj.voided; + result +} + +/// Score a heat's event log under `condition`, applying its **adjudications** +/// ([`Event::PenaltyApplied`] / [`Event::HeatVoided`], #13). +/// +/// This is the single home of penalty / heat-void application. It scores the raw +/// lap-gate passes the log carries, then folds in the heat's adjudications. Marshaling +/// corrections (void/insert/adjust) are a *separate* fold ([`crate::event::score_marshaled`]); +/// that path calls [`apply_adjudications`] on the corrected stream so penalties and +/// marshaling compose without either fold knowing about the other. +pub fn score_with_adjudications( events: &[Event], condition: WinCondition, race_start: SourceTime, @@ -225,45 +345,122 @@ pub fn score_events( _ => None, }) .collect(); - score(&passes, condition, race_start) + score_inner( + &passes, + condition, + race_start, + &Adjudications::collect(events), + ) +} + +/// Score already-grouped/corrected `passes`, applying the adjudications carried by `events`. +/// +/// Used by the marshaling-aware path ([`crate::event::score_marshaled`]): it has already +/// folded void/insert/adjust into a corrected pass stream, so here we only re-derive the +/// adjudications from the same log and apply them — penalties and heat-void compose with +/// marshaling without either fold knowing about the other. +pub(crate) fn apply_adjudications( + passes: &[Pass], + condition: WinCondition, + race_start: SourceTime, + events: &[Event], +) -> HeatResult { + score_inner( + passes, + condition, + race_start, + &Adjudications::collect(events), + ) +} + +/// Convenience wrapper over a canonical [`Event`] log: filters to lap-gate [`Pass`]es, +/// scores them, and applies any adjudications the log carries +/// ([`Event::PenaltyApplied`] / [`Event::HeatVoided`]) — so callers holding a heat's +/// event log (e.g. from the mock-RH harness) get the fully-adjudicated result without +/// pre-filtering. A log with no penalties scores exactly as [`score`] does. +pub fn score_events( + events: &[Event], + condition: WinCondition, + race_start: SourceTime, +) -> HeatResult { + score_with_adjudications(events, condition, race_start) } -/// Assemble a [`HeatResult`] from `(competitor, laps, metric, rank_key)` rows. +/// Assemble a [`HeatResult`] from `(competitor, laps, metric, rank_key)` rows, applying +/// `adj`'s disqualifications. /// -/// `rank_key` is a total, deterministic ordering key (smaller = better). Rows are -/// sorted by `(rank_key, competitor)` so the competitor key is the final tie-break -/// and the order is always total; competitors whose `rank_key` is *equal* (the part -/// the condition cares about) share a position, with the next distinct group's -/// position skipping past them (1, 2, 2, 4). -fn rank(rows: Vec<(CompetitorKey, u32, Metric, K)>) -> HeatResult { - let mut rows = rows; - // Total order: rank key first, competitor key as the final deterministic - // tie-break so two rows are never "equal" for sorting purposes. - rows.sort_by(|a, b| a.3.cmp(&b.3).then_with(|| a.0.cmp(&b.0))); +/// `rank_key` is a total, deterministic ordering key (smaller = better; any +/// [`Penalty::TimeAdded`] is already folded into it by the per-condition scorer). Rows +/// are sorted by `(disqualified, rank_key, competitor)`: **disqualified competitors sink +/// below every non-disqualified one** regardless of on-track result, then within each +/// group the rank key orders and the competitor key is the final deterministic tie-break. +/// Competitors whose `(disqualified, rank_key)` is *equal* share a position, with the next +/// distinct group's position skipping past them (1, 2, 2, 4). DQ'd placements carry +/// [`Placement::disqualified`] `= true`. +fn rank( + rows: Vec<(CompetitorKey, u32, Metric, K)>, + adj: &Adjudications, +) -> HeatResult { + // Pair each row with its DQ flag; the flag is the *primary* sort key (false < true), + // so every disqualified competitor ranks after every non-disqualified one. + let mut rows: Vec<(bool, CompetitorKey, u32, Metric, K)> = rows + .into_iter() + .map(|(competitor, laps, metric, key)| { + ( + adj.is_dq(&competitor.competitor), + competitor, + laps, + metric, + key, + ) + }) + .collect(); + // Total order: DQ first, then rank key, then competitor key as the final + // deterministic tie-break so two rows are never "equal" for sorting purposes. + rows.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.4.cmp(&b.4)) + .then_with(|| a.1.cmp(&b.1)) + }); let mut places = Vec::with_capacity(rows.len()); - let mut prev_key: Option = None; + // A position groups by the *ranking* identity that competitors share: the DQ flag + // plus the rank key. A DQ'd competitor never shares a position with a non-DQ'd one. + let mut prev_group: Option<(bool, K)> = None; let mut position = 0u32; - for (index, (competitor, laps, metric, key)) in rows.into_iter().enumerate() { - // A new position whenever the *ranking* key changes; equal ranking keys - // share the position of the first row in their group. - if prev_key.as_ref() != Some(&key) { + for (index, (disqualified, competitor, laps, metric, key)) in rows.into_iter().enumerate() { + let group = (disqualified, key); + if prev_group.as_ref() != Some(&group) { position = (index as u32) + 1; - prev_key = Some(key.clone()); + prev_group = Some(group); } places.push(Placement { competitor, position, laps, metric, + disqualified, }); } - HeatResult { places } + HeatResult { + places, + voided: false, + } } /// Timed: count laps whose completing pass is strictly before the cutoff, rank by /// count desc then earlier last-counted-lap completion. -fn score_timed(runs: Vec, race_start: SourceTime, window_micros: i64) -> HeatResult { +/// +/// `TimeAdded` here is a **pure lap-count** condition, so the penalty cannot change the +/// lap count; per the recorded rule it is folded into the **tie-break time** (the last +/// counted lap's completion), worsening a penalised competitor's standing against others +/// on the same lap count without inventing or removing laps. +fn score_timed( + runs: Vec, + race_start: SourceTime, + window_micros: i64, + adj: &Adjudications, +) -> HeatResult { let cutoff = race_start.micros + window_micros; let rows = runs .into_iter() @@ -277,22 +474,29 @@ fn score_timed(runs: Vec, race_start: SourceTime, window_micros: i64) -> He .collect(); let count = counted.len() as u32; let last_at = counted.last().map(|lap| lap.at); + let added = adj.added(&run.competitor.competitor); // Rank key: fewer laps is worse (negate count so smaller = better), then - // earlier last-lap completion is better. `i64::MAX` for "no lap" sorts a - // lapless competitor behind everyone with a lap at the same (zero) count. + // earlier last-lap completion is better, with any TimeAdded worsening it. + // `i64::MAX` for "no lap" sorts a lapless competitor behind everyone with a + // lap at the same (zero) count. let key = ( -(count as i64), - last_at.map(|t| t.micros).unwrap_or(i64::MAX), + last_at + .map(|t| t.micros.saturating_add(added)) + .unwrap_or(i64::MAX), ); (run.competitor, count, Metric::LastLapAt(last_at), key) }) .collect(); - rank(rows) + rank(rows, adj) } /// First-to-N: rank by who reached lap `n` earliest; non-reachers after, by laps /// desc then last-lap completion. -fn score_first_to_laps(runs: Vec, n: u32) -> HeatResult { +/// +/// `TimeAdded` worsens the **deciding time**: a reacher's reach-time and a non-reacher's +/// last-lap tie-break time both shift later by the accumulated penalty. +fn score_first_to_laps(runs: Vec, n: u32, adj: &Adjudications) -> HeatResult { let rows = runs .into_iter() .map(|run| { @@ -304,27 +508,33 @@ fn score_first_to_laps(runs: Vec, n: u32) -> HeatResult { None }; let last_at = run.laps.last().map(|lap| lap.at); + let added = adj.added(&run.competitor.competitor); // Reachers (group 0) sort ahead of non-reachers (group 1). Within - // reachers, earlier reach-time wins. Within non-reachers, more laps then - // earlier last-lap completion. + // reachers, earlier (penalty-worsened) reach-time wins. Within non-reachers, + // more laps then earlier (penalty-worsened) last-lap completion. let key = match reached_at { - Some(t) => (0i8, t.micros, 0i64, 0i64), + Some(t) => (0i8, t.micros.saturating_add(added), 0i64, 0i64), None => ( 1i8, 0, -(count as i64), - last_at.map(|t| t.micros).unwrap_or(i64::MAX), + last_at + .map(|t| t.micros.saturating_add(added)) + .unwrap_or(i64::MAX), ), }; (run.competitor, count, Metric::ReachedAt(reached_at), key) }) .collect(); - rank(rows) + rank(rows, adj) } /// Best single lap: rank by smallest lap duration; ties break by when that lap was /// set; no-lap competitors last. -fn score_best_lap(runs: Vec) -> HeatResult { +/// +/// `TimeAdded` worsens the **deciding time** by lengthening the best-lap duration the +/// competitor is ranked on (the on-track `metric` is left unchanged for display). +fn score_best_lap(runs: Vec, adj: &Adjudications) -> HeatResult { let rows = runs .into_iter() .map(|run| { @@ -340,10 +550,14 @@ fn score_best_lap(runs: Vec) -> HeatResult { }) .copied(); let best_micros = best.map(|lap| lap.duration_micros); - // Smaller duration is better; `i64::MAX` parks no-lap competitors last. - // Second key (set-time) makes equal-duration laps a total order. + let added = adj.added(&run.competitor.competitor); + // Smaller (penalty-lengthened) duration is better; `i64::MAX` parks no-lap + // competitors last. Second key (set-time) makes equal-duration laps a total + // order. let key = ( - best_micros.unwrap_or(i64::MAX), + best_micros + .map(|d| d.saturating_add(added)) + .unwrap_or(i64::MAX), best.map(|lap| lap.at.micros).unwrap_or(i64::MAX), ); ( @@ -354,13 +568,16 @@ fn score_best_lap(runs: Vec) -> HeatResult { ) }) .collect(); - rank(rows) + rank(rows, adj) } /// Best consecutive `n`: rank by smallest sum over any `n` consecutive laps; ties /// break by the completion time of the window's last lap; under-`n` competitors /// last. -fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult { +/// +/// `TimeAdded` worsens the **deciding time** by adding to the best window's sum the +/// competitor is ranked on (the on-track `metric` is left unchanged for display). +fn score_best_consecutive(runs: Vec, n: u32, adj: &Adjudications) -> HeatResult { let n = n.max(1) as usize; let rows = runs .into_iter() @@ -378,10 +595,11 @@ fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult { }) .min_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); let best_sum = best.map(|(sum, _)| sum); - // Smaller sum is better; no window (fewer than `n` laps) sorts last, - // ordered among themselves by lap count desc. + let added = adj.added(&run.competitor.competitor); + // Smaller (penalty-lengthened) sum is better; no window (fewer than `n` laps) + // sorts last, ordered among themselves by lap count desc. let key = match best { - Some((sum, end_at)) => (0i8, sum, end_at, 0i64), + Some((sum, end_at)) => (0i8, sum.saturating_add(added), end_at, 0i64), None => (1i8, 0, 0, -(count as i64)), }; ( @@ -392,13 +610,13 @@ fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult { ) }) .collect(); - rank(rows) + rank(rows, adj) } #[cfg(test)] mod tests { use super::*; - use gridfpv_events::{AdapterId, CompetitorRef, GateIndex}; + use gridfpv_events::{AdapterId, CompetitorRef, GateIndex, HeatId}; const ADAPTER: &str = "vd"; @@ -796,4 +1014,199 @@ mod tests { let r = score(&passes, WinCondition::BestLap, start()); assert_eq!(r.places.len(), 2); } + + // --- Adjudications: penalties & heat-void (#13) ------------------------- + + /// The lap-gate passes of a whole run, wrapped as `Event::Pass`es. + fn pass_events(competitor: &str, ats: &[i64]) -> Vec { + run(competitor, ats).into_iter().map(Event::Pass).collect() + } + + /// A `PenaltyApplied` for `competitor` in a fixed heat. + fn penalty(competitor: &str, penalty: Penalty) -> Event { + Event::PenaltyApplied { + heat: HeatId("h".into()), + competitor: CompetitorRef(competitor.into()), + penalty, + } + } + + #[test] + fn clean_log_score_events_matches_pure_score() { + // No adjudications: the adjudicated path equals the pure scorer exactly, and + // the additive flags are all false. + let events = pass_events("A", &[0, 2_000_000, 4_000_000]); + let cond = WinCondition::BestLap; + let r = score_events(&events, cond, start()); + let pure = score(&run("A", &[0, 2_000_000, 4_000_000]), cond, start()); + assert_eq!(r, pure); + assert!(!r.voided); + assert!(r.places.iter().all(|p| !p.disqualified)); + } + + #[test] + fn disqualify_drops_leader_to_last_and_shifts_others_up() { + // Timed: A leads (3 laps), B (2), C (1) → A,B,C. DQ A: A sinks to last, B and C + // shift up, and A is flagged disqualified. + let mut events = pass_events("A", &[0, 5_000_000, 10_000_000, 15_000_000]); + events.extend(pass_events("B", &[0, 6_000_000, 13_000_000])); + events.extend(pass_events("C", &[0, 7_000_000])); + events.push(penalty("A", Penalty::Disqualify)); + + let r = score_events( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + + assert_eq!(place(&r, "B").position, 1); + assert_eq!(place(&r, "C").position, 2); + assert_eq!(place(&r, "A").position, 3); + assert!(place(&r, "A").disqualified); + assert!(!place(&r, "B").disqualified); + assert!(!place(&r, "C").disqualified); + // The DQ does not erase A's on-track laps in the metric — only the ranking moves. + assert_eq!(place(&r, "A").laps, 3); + assert!(!r.voided); + } + + #[test] + fn disqualify_two_leaders_both_sink_below_the_field() { + // DQ both A (3 laps) and B (2): C (1 lap) is now first; the two DQ'd competitors + // rank behind it, ordered among themselves by their on-track standing then key. + let mut events = pass_events("A", &[0, 5_000_000, 10_000_000, 15_000_000]); + events.extend(pass_events("B", &[0, 6_000_000, 13_000_000])); + events.extend(pass_events("C", &[0, 7_000_000])); + events.push(penalty("A", Penalty::Disqualify)); + events.push(penalty("B", Penalty::Disqualify)); + + let r = score_events( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + + assert_eq!(place(&r, "C").position, 1); + assert!(!place(&r, "C").disqualified); + // A (3 laps) still beats B (2 laps) *within* the disqualified group. + assert_eq!(place(&r, "A").position, 2); + assert_eq!(place(&r, "B").position, 3); + assert!(place(&r, "A").disqualified); + assert!(place(&r, "B").disqualified); + } + + #[test] + fn time_added_reorders_a_best_lap_result() { + // BestLap: A's best lap is 2.0s, B's is 2.2s → A first. Add 0.5s to A's deciding + // time (2.0 → 2.5) and now B (2.2) wins; A drops to 2nd. The on-track metric is + // unchanged (still 2.0s for A) — only the ranking reflects the penalty. + let mut events = pass_events("A", &[0, 3_000_000, 5_000_000, 9_000_000]); + events.extend(pass_events("B", &[0, 2_500_000, 4_700_000])); + events.push(penalty("A", Penalty::TimeAdded { micros: 500_000 })); + + let r = score_events(&events, WinCondition::BestLap, start()); + + assert_eq!(place(&r, "B").position, 1); + assert_eq!(place(&r, "A").position, 2); + assert_eq!( + place(&r, "A").metric, + Metric::BestLapMicros(Some(2_000_000)) + ); + } + + #[test] + fn time_added_reorders_a_timed_tiebreak() { + // Timed, equal lap count (2 each). Without penalty B (last lap 9.0s) beats A + // (last lap 9.5s). Add 1.0s to B's deciding time (9.0 → 10.0): A (9.5) now wins. + let mut events = pass_events("A", &[0, 5_000_000, 9_500_000]); + events.extend(pass_events("B", &[0, 4_000_000, 9_000_000])); + events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 })); + + let r = score_events( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + + assert_eq!(place(&r, "A").laps, 2); + assert_eq!(place(&r, "B").laps, 2); + assert_eq!(place(&r, "A").position, 1); + assert_eq!(place(&r, "B").position, 2); + } + + #[test] + fn time_added_accumulates_across_penalties() { + // Two +1.0s penalties on B stack to +2.0s. BestLap: A 2.0s, B 1.5s. B's deciding + // time becomes 1.5 + 2.0 = 3.5s, so A (2.0) wins despite B's faster raw lap. + let mut events = pass_events("A", &[0, 2_000_000]); + events.extend(pass_events("B", &[0, 1_500_000])); + events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 })); + events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 })); + + let r = score_events(&events, WinCondition::BestLap, start()); + assert_eq!(place(&r, "A").position, 1); + assert_eq!(place(&r, "B").position, 2); + } + + #[test] + fn heat_voided_flags_the_result() { + // A clean 2-competitor heat, then a HeatVoided: the result is flagged voided but + // its on-track places are still scored (the standing remains visible). + let mut events = pass_events("A", &[0, 5_000_000, 10_000_000]); + events.extend(pass_events("B", &[0, 6_000_000])); + events.push(Event::HeatVoided { + heat: HeatId("h".into()), + }); + + let r = score_events( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + + assert!(r.voided); + assert_eq!(place(&r, "A").position, 1); + assert_eq!(place(&r, "B").position, 2); + } + + #[test] + fn penalty_and_marshaling_compose() { + // A's middle pass is a phantom voided by marshaling (A: 2 laps → 1), and B is + // disqualified. Score through the adjudicated wrapper over the *corrected* stream + // (here via crate::event::score_marshaled). Expect A first (its sole remaining + // lap), B disqualified to last. + use crate::event::score_marshaled; + use gridfpv_events::LogRef; + + let mut events: Vec = Vec::new(); + events.push(Event::Pass(pass("A", 0, 0))); // offset 0 + events.push(Event::Pass(pass("A", 2_000_000, 1))); // offset 1 — phantom + events.push(Event::Pass(pass("A", 6_000_000, 2))); // offset 2 + events.extend(pass_events("B", &[0, 4_000_000, 8_000_000])); // B: 2 laps + events.push(Event::DetectionVoided { target: LogRef(1) }); + events.push(penalty("B", Penalty::Disqualify)); + + let r = score_marshaled( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + + // Marshaling collapsed A to a single lap (0 → 6.0s). + assert_eq!(place(&r, "A").laps, 1); + // B disqualified despite 2 on-track laps: ranked last and flagged. + assert_eq!(place(&r, "A").position, 1); + assert_eq!(place(&r, "B").position, 2); + assert!(place(&r, "B").disqualified); + } } diff --git a/crates/engine/src/single_elim.rs b/crates/engine/src/single_elim.rs index fc78de4..32c6666 100644 --- a/crates/engine/src/single_elim.rs +++ b/crates/engine/src/single_elim.rs @@ -279,8 +279,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 66fd957..896fa04 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -251,6 +251,7 @@ mod tests { position, laps, metric, + ..Default::default() } } @@ -265,6 +266,7 @@ mod tests { placement(name, (i as u32) + 1, 0, Metric::BestLapMicros(*micros)) }) .collect(), + ..Default::default() } } @@ -278,6 +280,7 @@ mod tests { placement(name, (i as u32) + 1, *laps, Metric::LastLapAt(None)) }) .collect(), + ..Default::default() } } @@ -416,6 +419,7 @@ mod tests { placement("A", 1, 3, Metric::BestConsecutiveMicros(a)), placement("B", 2, 3, Metric::BestConsecutiveMicros(b)), ], + ..Default::default() }; let r1 = CompletedHeat::new("round-1", round(Some(6_000_000), Some(5_500_000))); // A improves to 5.0s in round 2; B holds at 5.5s. A's best (5.0) beats B's (5.5). diff --git a/crates/engine/src/zippyq.rs b/crates/engine/src/zippyq.rs index dfaf4d0..307a8ef 100644 --- a/crates/engine/src/zippyq.rs +++ b/crates/engine/src/zippyq.rs @@ -229,8 +229,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } diff --git a/crates/engine/tests/double_elim_live.rs b/crates/engine/tests/double_elim_live.rs index 047369c..d353b79 100644 --- a/crates/engine/tests/double_elim_live.rs +++ b/crates/engine/tests/double_elim_live.rs @@ -119,12 +119,19 @@ fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { position: next_pos, laps: 0, metric: gridfpv_engine::scoring::Metric::LastLapAt(None), + ..Default::default() }); next_pos += 1; } } - CompletedHeat::new(plan.heat.0.clone(), HeatResult { places }) + CompletedHeat::new( + plan.heat.0.clone(), + HeatResult { + places, + ..Default::default() + }, + ) } /// The seat node index behind a `node-{i}` competitor ref, if it has that shape. @@ -142,6 +149,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + disqualified: place.disqualified, } } diff --git a/crates/engine/tests/full_event_live.rs b/crates/engine/tests/full_event_live.rs index 9f3e32f..5b40e19 100644 --- a/crates/engine/tests/full_event_live.rs +++ b/crates/engine/tests/full_event_live.rs @@ -69,6 +69,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + disqualified: place.disqualified, } } @@ -143,12 +144,16 @@ fn run_real_heat(plan: &HeatPlan, winner: &CompetitorRef) -> HeatResult { position: next_pos, laps: 0, metric: Metric::LastLapAt(None), + ..Default::default() }); next_pos += 1; } } - HeatResult { places } + HeatResult { + places, + ..Default::default() + } } /// The intended winner of a heat: the highest seed present in the lineup. Seeds here are diff --git a/crates/engine/tests/multi_main_live.rs b/crates/engine/tests/multi_main_live.rs index 5547805..b97fee1 100644 --- a/crates/engine/tests/multi_main_live.rs +++ b/crates/engine/tests/multi_main_live.rs @@ -68,6 +68,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + disqualified: place.disqualified, } } @@ -131,12 +132,19 @@ fn run_main_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { position: next_pos, laps: 0, metric: gridfpv_engine::scoring::Metric::LastLapAt(None), + ..Default::default() }); next_pos += 1; } } - CompletedHeat::new(plan.heat.0.clone(), HeatResult { places }) + CompletedHeat::new( + plan.heat.0.clone(), + HeatResult { + places, + ..Default::default() + }, + ) } #[test] diff --git a/crates/engine/tests/single_elim_live.rs b/crates/engine/tests/single_elim_live.rs index 9c7c3ba..2018047 100644 --- a/crates/engine/tests/single_elim_live.rs +++ b/crates/engine/tests/single_elim_live.rs @@ -136,12 +136,19 @@ fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { position: next_pos, laps: 0, metric: gridfpv_engine::scoring::Metric::LastLapAt(None), + ..Default::default() }); next_pos += 1; } } - CompletedHeat::new(plan.heat.0.clone(), HeatResult { places }) + CompletedHeat::new( + plan.heat.0.clone(), + HeatResult { + places, + ..Default::default() + }, + ) } /// The seat node index behind a `node-{i}` competitor ref, if it has that shape. @@ -159,6 +166,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + disqualified: place.disqualified, } } From f3b2d8161eaa33c3ab3fc017e2413794a35a9daf Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 18:30:04 +0000 Subject: [PATCH 039/362] fix(adapters): rh_live event_kind match covers CompetitorRegistered (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #60 added Event::CompetitorRegistered but rh_live.rs's exhaustive match wasn't updated, so the adapters live test failed to compile under --features live. Core CI excludes the live feature (openssl), so only cargo xtask live caught it. Add the arm. (CI gap: core CI should compile-check the live tests — noted for the CI rework at the GitHub migration.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/adapters/tests/rh_live.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/adapters/tests/rh_live.rs b/crates/adapters/tests/rh_live.rs index e2c8a60..fa380ea 100644 --- a/crates/adapters/tests/rh_live.rs +++ b/crates/adapters/tests/rh_live.rs @@ -33,6 +33,7 @@ fn event_kind(e: &Event) -> &'static str { Event::SessionStarted { .. } => "SessionStarted", Event::SessionEnded { .. } => "SessionEnded", Event::CompetitorSeen { .. } => "CompetitorSeen", + Event::CompetitorRegistered { .. } => "CompetitorRegistered", Event::Pass(_) => "Pass", Event::HeatScheduled { .. } => "HeatScheduled", Event::HeatStateChanged { .. } => "HeatStateChanged", From 28c58af3bb54a3b3d9ec23522d0eb88c8d783eb2 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 18:34:11 +0000 Subject: [PATCH 040/362] fix(rd-console): default Director address to the served origin, not localhost The console is served by the Director, so its base URL is the page's own origin. Defaulting the login field to localhost:8080 meant a remote browser tried to reach ITS OWN machine -> Failed to fetch + snapshotting/reconnecting bounce. Prefer location.origin. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/screens/Login.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/Login.svelte b/frontend/apps/rd-console/src/screens/Login.svelte index e914997..4b5e0b6 100644 --- a/frontend/apps/rd-console/src/screens/Login.svelte +++ b/frontend/apps/rd-console/src/screens/Login.svelte @@ -10,8 +10,11 @@ let { session }: { session: Session } = $props(); - // Seed the field from any restored base URL once; the input owns it thereafter. - let baseUrl = $state(untrack(() => session.baseUrl) || 'http://localhost:8080'); + // The console is served BY the Director, so the Director is this page's own origin — + // default to it (prevents the "localhost from the wrong machine" Failed-to-fetch trap). + let baseUrl = $state( + globalThis.location?.origin || untrack(() => session.baseUrl) || 'http://localhost:8080' + ); let token = $state(''); function submit(e: Event) { From 276d94df75e9d074507aa5ec87733b05affca2a6 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 18:44:16 +0000 Subject: [PATCH 041/362] docs(roadmap): pivot v0.4 to vertical feature slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe how v0.4 is delivered — from horizontal layers (engine → server → console) to vertical feature slices, each built backend → API → UI until it genuinely works. Root realization: the missing backbone is the Event aggregate (you are always inside an event; an event owns timers, pilots, heats, format, results). - Mark v0.1–v0.3 shipped and the v0.4 protocol-server / Svelte-console / contract-suite foundation as built. - Reframe v0.4 as "Direct a single race (RD console) — vertical slices" with Slices 0–7 (design system, events & workspace, timer config, pilot registration, heat building, run a heat, marshaling & results, multi-heat formats). - Add a "How we build (v0.4+)" callout: vertical slices over horizontal layers; design is foundation-first; entities carry auto-generated unique IDs (names/callsigns are display). - Note v0.5+ (streaming/broadcast, cloud, …) follow once a single race and a full multi-heat event are directable from the console. Prose-only; HTML kept well-formed and in the doc's existing style. Part of #13 (roadmap pivot to vertical slices). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/roadmap.html | 87 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/docs/roadmap.html b/docs/roadmap.html index da86dd6..8d6bd26 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -54,13 +54,35 @@

      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,25 @@ 

      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 six format generators are all in + the tree. As designed, v0.2 already ingests a live (dockerized) source — "shadow mode." +

      +

      + The v0.4 foundation is also built: the embedded axum + protocol server (snapshot + WS stream, scoping, auth), the + Svelte console shell + generated types, and the + contract-suite / observability backbone. After a hands-on UI review, + the remaining v0.4 work — actually directing a race from the console — is being + rebuilt as vertical feature slices (see §1 and the v0.4 section below).

      @@ -112,14 +143,42 @@

      v0.3 — Race engine

      Done when: a full event (qualifying → bracket → winner) runs from a recorded source with correct results and marshaling; generators are table-tested.

      -

      v0.4 — Director server + RD console

      -

      Goal: a person can run a real event end to end. (Protocol, Clients)

      +

      v0.4 — Direct a single race (RD console) — vertical slices

      +

      Goal: a person can run a real event end to end from the RD console. (Protocol, Clients)

      +

      + The horizontal foundation — embedded axum protocol server (snapshot + WS + stream, scoping, bearer + LAN join-token auth), the Svelte console shell + + shared component library + generated types, and the contract-suite/observability backbone — + is built. The remaining work is the director workflow itself, delivered as + vertical slices (each backend → API → UI, working before the + next): +

        -
      • Embedded axum protocol server (snapshot + WS stream, scoping, bearer + LAN join-token auth).
      • -
      • Svelte frontend foundation + shared component library + generated types; the RD console (setup wizard, registration, live race control, marshaling, results).
      • -
      • Tauri shell + single-binary packaging for Windows/Linux/macOS.
      • +
      • Slice 0 — Design system foundation. Tokens, component library, and app + shell, built first so every later slice inherits a consistent UI.
      • +
      • Slice 1 — Events & workspace. The Event aggregate + itself: an always-present, non-persistent Practice event; a startup / event-picker + flow; you cannot act outside an event; and auth / connect to the served origin.
      • +
      • Slice 2 — Timer configuration. Per-event timing source — Sim, + RotorHazard URL, or future sources — chosen and validated inside the event.
      • +
      • Slice 3 — Pilot registration. Event-scoped pilots with auto-generated + IDs plus callsign / channel, and a working Registration UI.
      • +
      • Slice 4 — Heat building. Build heats from the registered roster — select + pilots, assign channels.
      • +
      • Slice 5 — Run a heat (live). Run a heat from the console with a + server-authoritative race clock.
      • +
      • Slice 6 — Marshaling & results. Void / insert / adjust laps and apply + penalties, with adjustments and penalties shown in the results UI.
      • +
      • Slice 7 — Multi-heat formats. Select a format and drive its progress — + the six generators already exist from v0.3.
      -

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

      +

      Packaging follows alongside: a Tauri shell + single-binary build for Windows/Linux/macOS.

      +

      Done when: a complete event — registration → heats → live races → marshaling → results, across a multi-heat format — runs live from the RD console against real/dockerized RH, and the single binary launches on all three OSes.

      + +

      + v0.5 and beyond stay as the later milestones — they begin once a single race, and a full + multi-heat event, is genuinely directable from the console (v0.4 complete). +

      v0.5 — Racer/spectator PWA

      Goal: racers and spectators can follow along on their phones. (Clients)

      From 5a16853dcb60effa12c30bea7c617a0291eeb39a Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 19:06:08 +0000 Subject: [PATCH 042/362] Design system foundation: dark-mode-first tokens, primitives, restyle (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 0 of the console design system — a modern, cohesive race-operations visual language in the spirit of Linear / Vercel / live-sports broadcast graphics. Replaces the "ugly, too simple, not modern" baseline at the foundation level so every surface inherits it. Token system (packages/components/src/tokens.css): - Dark-mode-first, with a light theme via `.theme-light` / `[data-theme]`. - One source of truth: a neutral slate ramp + electric "grid green" brand accent + semantic roles (surface/elevated/border/text tiers), the six heat-phase colors (Scheduled→Staged→Armed→Running→Finished→Scored), connection states, and success/warn/danger/info. - Type scale (Inter/system grotesk + tabular mono), spacing, radii, an elevation/shadow ramp, motion durations/easings, and focus rings. - Back-compat `--gf-color-*` aliases; honors prefers-reduced-motion. Base UI primitives (@gridfpv/components/primitives, framework-pure Svelte): Button (primary/secondary/ghost/danger × sizes, loading), Input, Select, Field, Card/Panel, Badge, StatusPill (phase + connection, pulses when live/running), Tabs (ARIA roving tabindex), Banner, Dialog (native ), and a Toast store + ToastHost. Accessible by default — real focus rings, roles, keyboard nav. App shell + screens: - New shell: branded sidebar (icon nav, active accent bar, Alt+digit hints), a sticky topbar with event/heat context + connection StatusPill, a global ToastHost, and a depth-washed dark canvas (apps/.../app.css). - Restyled Login (auth card), Live race control (phase-tinted race-ops HUD with a prominent clock + pulsing live state), New heat, Registration, Marshaling, Results, Setup wizard — and ConfirmButton/ErrorBanner now route through the Button/Banner primitives. Shared components (Leaderboard, StandingsTable, HeatSheet, RaceClock, PilotCard, BracketTree) migrated to the new tokens with medal glow, row hover, phase-colored pills, and elevation. Kept green: build/check/lint/test all pass; the browser e2e stays `1 passed` (role/label selectors plus the preserved `.conn-label`, `.heat-id .value`, `.laps`, `.phase`, `.match`, `.slot.winner`, `data-medal`, `data-mode` hooks). Confined to frontend/; no Rust/server/bindings changes. Part of #71 (Slice 0 design foundation). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 389 +++++++++++++----- frontend/apps/rd-console/src/app.css | 85 ++++ .../rd-console/src/lib/ConfirmButton.svelte | 69 +--- .../rd-console/src/lib/ErrorBanner.svelte | 42 +- frontend/apps/rd-console/src/main.ts | 2 + .../src/screens/LiveRaceControl.svelte | 221 +++++++--- .../apps/rd-console/src/screens/Login.svelte | 157 ++++--- .../rd-console/src/screens/Marshaling.svelte | 85 +++- .../rd-console/src/screens/NewHeat.svelte | 133 +++--- .../src/screens/Registration.svelte | 171 ++++---- .../rd-console/src/screens/Results.svelte | 46 +-- .../rd-console/src/screens/SetupWizard.svelte | 135 ++++-- .../components/src/BracketTree.svelte | 30 +- .../packages/components/src/HeatSheet.svelte | 75 +++- .../components/src/Leaderboard.svelte | 46 ++- .../packages/components/src/PilotCard.svelte | 39 +- .../packages/components/src/RaceClock.svelte | 8 +- .../components/src/StandingsTable.svelte | 54 ++- frontend/packages/components/src/index.ts | 17 +- .../components/src/primitives/Badge.svelte | 89 ++++ .../components/src/primitives/Banner.svelte | 115 ++++++ .../components/src/primitives/Button.svelte | 179 ++++++++ .../components/src/primitives/Card.svelte | 89 ++++ .../components/src/primitives/Dialog.svelte | 155 +++++++ .../components/src/primitives/Field.svelte | 67 +++ .../components/src/primitives/Input.svelte | 65 +++ .../components/src/primitives/Select.svelte | 80 ++++ .../src/primitives/StatusPill.svelte | 149 +++++++ .../components/src/primitives/Tabs.svelte | 109 +++++ .../src/primitives/ToastHost.svelte | 124 ++++++ .../components/src/primitives/toast.svelte.ts | 48 +++ frontend/packages/components/src/tokens.css | 349 +++++++++++++--- 32 files changed, 2770 insertions(+), 652 deletions(-) create mode 100644 frontend/apps/rd-console/src/app.css create mode 100644 frontend/packages/components/src/primitives/Badge.svelte create mode 100644 frontend/packages/components/src/primitives/Banner.svelte create mode 100644 frontend/packages/components/src/primitives/Button.svelte create mode 100644 frontend/packages/components/src/primitives/Card.svelte create mode 100644 frontend/packages/components/src/primitives/Dialog.svelte create mode 100644 frontend/packages/components/src/primitives/Field.svelte create mode 100644 frontend/packages/components/src/primitives/Input.svelte create mode 100644 frontend/packages/components/src/primitives/Select.svelte create mode 100644 frontend/packages/components/src/primitives/StatusPill.svelte create mode 100644 frontend/packages/components/src/primitives/Tabs.svelte create mode 100644 frontend/packages/components/src/primitives/ToastHost.svelte create mode 100644 frontend/packages/components/src/primitives/toast.svelte.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 8ff1126..094693c 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -1,9 +1,11 @@ @@ -76,7 +81,26 @@ {:else}
      -
      - {#if active === 'setup'} - - {:else if active === 'registration'} - - {:else if active === 'live'} - - {:else if active === 'marshaling'} - - {:else if active === 'results'} - - {/if} -
      +
      +
      +
      + {eventLabel} + +

      {activeScreen?.label}

      + {#if liveHeat} + Heat {liveHeat} + {/if} +
      +
      + +
      +
      + +
      + {#if active === 'setup'} + + {:else if active === 'registration'} + + {:else if active === 'live'} + + {:else if active === 'marshaling'} + + {:else if active === 'results'} + + {/if} +
      +
      + {/if} diff --git a/frontend/apps/rd-console/src/app.css b/frontend/apps/rd-console/src/app.css new file mode 100644 index 0000000..61ab339 --- /dev/null +++ b/frontend/apps/rd-console/src/app.css @@ -0,0 +1,85 @@ +/** + * RD console global base — the app canvas reset + defaults, sitting on top of + * the @gridfpv/components design tokens. Everything else styles via tokens; this + * file just establishes the dark page, sane box-sizing, scrollbar styling, and + * the document-level type defaults so the console reads as one cohesive surface. + */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: dark; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--gf-surface-sunken); + color: var(--gf-text); + font-family: var(--gf-font-family); + font-size: var(--gf-font-size-md); + line-height: var(--gf-line-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* A subtle radial wash on the canvas so the dark page has depth rather than a + * flat fill — broadcast-graphics feel, kept very low-contrast. */ +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient( + 1200px 600px at 18% -10%, + color-mix(in srgb, var(--gf-accent) 9%, transparent), + transparent 60% + ), + radial-gradient( + 900px 500px at 100% 0%, + color-mix(in srgb, var(--gf-info) 7%, transparent), + transparent 55% + ); +} + +:focus-visible { + outline: none; +} + +/* Modern, unobtrusive scrollbars that match the dark surface. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--gf-border-strong) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--gf-border-strong); + border-radius: var(--gf-radius-pill); + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--gf-text-faint); + background-clip: padding-box; +} + +code, +kbd, +samp { + font-family: var(--gf-font-mono); +} + +::selection { + background: var(--gf-accent-soft); +} diff --git a/frontend/apps/rd-console/src/lib/ConfirmButton.svelte b/frontend/apps/rd-console/src/lib/ConfirmButton.svelte index cef9b58..823a147 100644 --- a/frontend/apps/rd-console/src/lib/ConfirmButton.svelte +++ b/frontend/apps/rd-console/src/lib/ConfirmButton.svelte @@ -4,8 +4,13 @@ * confirm-on-destructive). On first click it flips into a two-step "Confirm? / * Cancel" state; the action only runs on the explicit confirm. Non-destructive * callers pass `confirm={false}` for a plain one-click button. + * + * Restyled by #71 to delegate to the shared `Button` primitive so every action + * in the console matches the design system. The caller's `variant` is mapped to + * the primitive's variants (`default` → secondary). */ import type { Snippet } from 'svelte'; + import { Button } from '@gridfpv/components'; let { onconfirm, @@ -25,6 +30,10 @@ let armed = $state(false); + const btnVariant = $derived( + variant === 'primary' ? 'primary' : variant === 'danger' ? 'danger' : 'secondary' + ); + function click() { if (!confirm) { onconfirm(); @@ -45,28 +54,12 @@ {#if armed} - - + + {:else} - + {/if} @@ -75,40 +68,4 @@ display: inline-flex; gap: var(--gf-space-2); } - .btn { - font-family: var(--gf-font-family); - font-size: var(--gf-font-size-sm); - font-weight: var(--gf-font-weight-medium); - padding: var(--gf-space-2) var(--gf-space-4); - border-radius: var(--gf-radius-sm); - border: 1px solid var(--gf-color-border); - background: var(--gf-color-surface); - color: var(--gf-color-text); - cursor: pointer; - } - .btn:hover:not(:disabled) { - border-color: var(--gf-color-accent); - } - .btn:disabled { - opacity: 0.45; - cursor: not-allowed; - } - .btn.primary { - background: var(--gf-color-accent); - color: var(--gf-color-accent-contrast); - border-color: var(--gf-color-accent); - } - .btn.danger, - .btn.confirm.danger { - border-color: var(--gf-color-danger); - color: var(--gf-color-danger); - } - .btn.confirm { - background: var(--gf-color-accent); - color: var(--gf-color-accent-contrast); - border-color: var(--gf-color-accent); - } - .btn.cancel { - background: transparent; - } diff --git a/frontend/apps/rd-console/src/lib/ErrorBanner.svelte b/frontend/apps/rd-console/src/lib/ErrorBanner.svelte index d6bd483..323d04e 100644 --- a/frontend/apps/rd-console/src/lib/ErrorBanner.svelte +++ b/frontend/apps/rd-console/src/lib/ErrorBanner.svelte @@ -3,48 +3,30 @@ * Surfaces a shared `ProtocolError` from a failed `CommandAck` (protocol.html §9.8) * in plain language for the RD (clients.html §5), with a dismiss. The error code is * shown small for support; the message is the human detail. + * + * Restyled by #71 to render through the shared `Banner` primitive (danger tone). */ import type { ProtocolError } from '@gridfpv/types'; + import { Banner } from '@gridfpv/components'; let { error, ondismiss }: { error: ProtocolError | undefined; ondismiss?: () => void } = $props(); {#if error} - + {/if} diff --git a/frontend/apps/rd-console/src/main.ts b/frontend/apps/rd-console/src/main.ts index 89f9e54..abbf2d7 100644 --- a/frontend/apps/rd-console/src/main.ts +++ b/frontend/apps/rd-console/src/main.ts @@ -2,6 +2,8 @@ import { mount } from 'svelte'; // The component library's design tokens — imported once per surface so every // @gridfpv/components widget themes off the same CSS custom properties. import '@gridfpv/components/tokens.css'; +// The console's global base (dark canvas, resets) — sits on top of the tokens. +import './app.css'; import App from './App.svelte'; const target = document.getElementById('app'); diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 422c202..6f1c244 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -9,7 +9,7 @@ * destructive off-ramps (Abort/Restart/Discard) confirm before firing. A failed * `CommandAck` surfaces through the shared `ErrorBanner`. */ - import { HeatSheet, RaceClock, Leaderboard } from '@gridfpv/components'; + import { HeatSheet, RaceClock, Leaderboard, Card } from '@gridfpv/components'; import type { HeatId, HeatResult, LiveRaceState } from '@gridfpv/types'; import { ACTION_ORDER, @@ -108,21 +108,34 @@
      -
      -
      - {#if heat} - Current heat - {heat} - {:else} - No heat on the timer - {/if} +
      +
      + Current heat +
      + {#if heat} + {heat} + {:else} + — none on the timer — + {/if} +
      +
      + +
      + Phase +
      + {phase} +
      -
      {phase}
      -
      - + +
      + Heat time +
      + +
      + {#if live?.on_deck} -
      +
      On deck {live.on_deck}
      @@ -136,37 +149,38 @@
      - {#each ACTION_ORDER as action (action)} - {@const legal = isActionLegal(phase, action)} - fire(action)} - confirm={isDestructive(action)} - disabled={!legal || !heat} - variant={action === primary ? 'primary' : isDestructive(action) ? 'danger' : 'default'} - title={actionDescription(action)} - > - {action} - - {/each} + Transitions +
      + {#each ACTION_ORDER as action (action)} + {@const legal = isActionLegal(phase, action)} + fire(action)} + confirm={isDestructive(action)} + disabled={!legal || !heat} + variant={action === primary ? 'primary' : isDestructive(action) ? 'danger' : 'default'} + title={actionDescription(action)} + > + {action} + + {/each} +
      -
      -

      Heat sheet

      + {#if live} {:else}

      Waiting for a live heat…

      {/if} -
      -
      -

      Live standing

      + + {#if liveResult} {:else}

      No laps yet.

      {/if} -
      +
      @@ -174,58 +188,153 @@ .live-control { display: flex; flex-direction: column; - gap: var(--gf-space-4); + gap: var(--gf-space-5); } - .top { - display: flex; + + /* ── HUD: the race-ops command bar ───────────────────────────────────────── */ + .hud { + --_phase: var(--gf-phase-scheduled); + display: grid; + grid-template-columns: 1fr auto auto auto; align-items: center; gap: var(--gf-space-8); - flex-wrap: wrap; + padding: var(--gf-space-5) var(--gf-space-6); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + background: linear-gradient( + 100deg, + color-mix(in srgb, var(--_phase) 10%, var(--gf-elevated)), + var(--gf-elevated) 60% + ); + box-shadow: var(--gf-shadow-sm), var(--gf-shadow-inset); + position: relative; + overflow: hidden; + } + .hud::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--_phase); + } + .hud[data-phase='Scheduled'] { + --_phase: var(--gf-phase-scheduled); + } + .hud[data-phase='Staged'] { + --_phase: var(--gf-phase-staged); + } + .hud[data-phase='Armed'] { + --_phase: var(--gf-phase-armed); + } + .hud[data-phase='Running'] { + --_phase: var(--gf-phase-running); + } + .hud[data-phase='Finished'] { + --_phase: var(--gf-phase-finished); + } + .hud[data-phase='Scored'] { + --_phase: var(--gf-phase-scored); } .label { display: block; - font-size: var(--gf-font-size-xs); - color: var(--gf-color-text-muted); + font-size: var(--gf-font-size-2xs); + color: var(--gf-text-muted); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: var(--gf-tracking-caps); + font-weight: var(--gf-font-weight-semibold); + margin-bottom: var(--gf-space-1); } .value { - font-size: var(--gf-font-size-lg); + font-size: var(--gf-font-size-xl); font-weight: var(--gf-font-weight-bold); + letter-spacing: var(--gf-tracking-tight); + font-variant-numeric: tabular-nums; + } + .value.none { + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-medium); + color: var(--gf-text-faint); } .phase { + display: inline-flex; + align-items: center; + gap: var(--gf-space-2); padding: var(--gf-space-2) var(--gf-space-4); - border-radius: var(--gf-radius-sm); - background: var(--gf-color-surface-alt); - border: 1px solid var(--gf-color-border); + border-radius: var(--gf-radius-pill); + background: color-mix(in srgb, var(--_phase) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--_phase) 45%, transparent); + color: color-mix(in srgb, var(--_phase) 90%, var(--gf-text)); font-weight: var(--gf-font-weight-bold); + font-size: var(--gf-font-size-md); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-wide); + } + .phase-dot { + width: 0.55em; + height: 0.55em; + border-radius: 50%; + background: var(--_phase); } - .phase[data-phase='Running'] { - background: var(--gf-color-live); - color: var(--gf-color-accent-contrast); - border-color: var(--gf-color-live); + .hud[data-phase='Running'] .phase-dot { + animation: hud-pulse 1.4s var(--gf-ease-out) infinite; + } + @keyframes hud-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--_phase) 70%, transparent); + } + 70% { + box-shadow: 0 0 0 0.6em transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } + } + @media (prefers-reduced-motion: reduce) { + .hud[data-phase='Running'] .phase-dot { + animation: none; + } } + + /* ── Transition controls ─────────────────────────────────────────────────── */ .controls { + display: flex; + flex-direction: column; + gap: var(--gf-space-3); + padding: var(--gf-space-4) var(--gf-space-5); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + background: var(--gf-surface); + } + .controls-label { + font-size: var(--gf-font-size-2xs); + color: var(--gf-text-muted); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + font-weight: var(--gf-font-weight-semibold); + } + .controls-row { display: flex; flex-wrap: wrap; gap: var(--gf-space-3); - padding: var(--gf-space-4); - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-md); - background: var(--gf-color-surface); } + .panels { display: grid; grid-template-columns: 1fr 1fr; - gap: var(--gf-space-6); - } - .panel h3 { - font-size: var(--gf-font-size-md); - margin: 0 0 var(--gf-space-3); + gap: var(--gf-space-5); } .empty { - color: var(--gf-color-text-muted); + color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); + padding: var(--gf-space-5); + } + @media (max-width: 75rem) { + .hud { + grid-template-columns: 1fr 1fr; + gap: var(--gf-space-5); + } } @media (max-width: 60rem) { .panels { diff --git a/frontend/apps/rd-console/src/screens/Login.svelte b/frontend/apps/rd-console/src/screens/Login.svelte index 4b5e0b6..862c627 100644 --- a/frontend/apps/rd-console/src/screens/Login.svelte +++ b/frontend/apps/rd-console/src/screens/Login.svelte @@ -1,11 +1,13 @@ diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index c13d8fe..597b50f 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -164,34 +164,46 @@ .marshaling { display: flex; flex-direction: column; - gap: var(--gf-space-4); + gap: var(--gf-space-5); } h2 { - font-size: var(--gf-font-size-lg); + font-size: var(--gf-font-size-xl); margin: 0; + letter-spacing: var(--gf-tracking-tight); } .heat { - color: var(--gf-color-text-muted); + color: var(--gf-text-muted); font-weight: var(--gf-font-weight-normal); } .muted { - color: var(--gf-color-text-muted); + color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); margin: var(--gf-space-1) 0 0; } .laps { display: flex; flex-wrap: wrap; - gap: var(--gf-space-6); + gap: var(--gf-space-4); + } + .comp { + flex: 1 1 14rem; + padding: var(--gf-space-4); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + background: var(--gf-elevated); + box-shadow: var(--gf-shadow-xs); } .comp h4 { margin: 0 0 var(--gf-space-2); font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-semibold); } .comp ol { margin: 0; padding-left: var(--gf-space-6); font-size: var(--gf-font-size-sm); + color: var(--gf-text-secondary); + font-family: var(--gf-font-mono); } .actions { display: grid; @@ -199,51 +211,80 @@ gap: var(--gf-space-4); } fieldset { - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-md); - padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + padding: var(--gf-space-4) var(--gf-space-5); display: flex; flex-wrap: wrap; align-items: end; gap: var(--gf-space-3); + background: var(--gf-elevated); } legend { - font-weight: var(--gf-font-weight-bold); + font-weight: var(--gf-font-weight-semibold); font-size: var(--gf-font-size-sm); + padding: 0 var(--gf-space-2); } label { display: flex; flex-direction: column; - gap: var(--gf-space-1); - font-size: var(--gf-font-size-xs); - color: var(--gf-color-text-muted); + gap: var(--gf-space-2); + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + color: var(--gf-text-muted); } input, select { font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); - padding: var(--gf-space-1) var(--gf-space-2); - border: 1px solid var(--gf-color-border); + font-weight: var(--gf-font-weight-normal); + text-transform: none; + letter-spacing: normal; + height: 2.1rem; + padding: 0 var(--gf-space-3); + border: 1px solid var(--gf-border); border-radius: var(--gf-radius-sm); - background: var(--gf-color-surface); - color: var(--gf-color-text); + background: var(--gf-surface-sunken); + color: var(--gf-text); + } + input:focus, + select:focus { + outline: none; + border-color: var(--gf-accent); + box-shadow: 0 0 0 3px var(--gf-accent-soft); } button { font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); - padding: var(--gf-space-2) var(--gf-space-4); + font-weight: var(--gf-font-weight-semibold); + height: 2.1rem; + padding: 0 var(--gf-space-4); border-radius: var(--gf-radius-sm); - border: 1px solid var(--gf-color-border); - background: var(--gf-color-surface); - color: var(--gf-color-text); + border: 1px solid var(--gf-border); + background: var(--gf-elevated); + color: var(--gf-text); cursor: pointer; + transition: + background var(--gf-motion-fast) var(--gf-ease-out), + border-color var(--gf-motion-fast) var(--gf-ease-out); + } + button:hover:not(:disabled) { + background: var(--gf-elevated-hover); + border-color: var(--gf-border-strong); + } + button:focus-visible { + outline: none; + box-shadow: var(--gf-focus-ring); } button:disabled { - opacity: 0.45; + opacity: 0.5; cursor: not-allowed; } .danger-zone { - border-color: var(--gf-color-danger); + border-color: color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + background: var(--gf-danger-soft); } @media (max-width: 60rem) { .actions { diff --git a/frontend/apps/rd-console/src/screens/NewHeat.svelte b/frontend/apps/rd-console/src/screens/NewHeat.svelte index 83d38de..7d26840 100644 --- a/frontend/apps/rd-console/src/screens/NewHeat.svelte +++ b/frontend/apps/rd-console/src/screens/NewHeat.svelte @@ -12,6 +12,7 @@ * and aborts the rest of the batch so the RD can correct and retry. */ import type { HeatId } from '@gridfpv/types'; + import { Button, Input } from '@gridfpv/components'; import { defineHeatCommands, isHeatValid, @@ -58,37 +59,41 @@
      -

      New heat

      -

      - Type the heat id and the pilots flying it. Each name is registered on - sim and becomes the heat's lineup. -

      +
      +

      New heat

      +

      + Type the heat id and the pilots flying it. Each name is registered on + sim and becomes the heat's lineup. +

      +
      -
      diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index 606ebc6..87d604b 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -7,7 +7,7 @@ * heats → `BracketTree` (via the `bracketFromOutcome` view-model derivation). Export * is a lossless JSON download of whichever projection is shown. */ - import { Leaderboard, StandingsTable, BracketTree } from '@gridfpv/components'; + import { Leaderboard, StandingsTable, BracketTree, Button, Card } from '@gridfpv/components'; import type { HeatResult, RankEntry, EventOutcome } from '@gridfpv/types'; import { bracketFromOutcome, downloadJson, toExportJson } from '../lib/results.js'; @@ -34,32 +34,31 @@

      Results

      - +
      {#if heatResult} -
      -

      Heat result

      + -
      + {/if} {#if standings && standings.length > 0} -
      -

      Standings

      + -
      + {/if} {#if bracket && bracket.rounds.length > 0} -
      -

      Bracket

      + -
      + {/if} {#if !heatResult && !(standings && standings.length) && !(bracket && bracket.rounds.length)} -

      No results yet. They appear here as heats are scored.

      + +

      No results yet. They appear here as heats are scored.

      +
      {/if}
      @@ -67,33 +66,20 @@ .results { display: flex; flex-direction: column; - gap: var(--gf-space-6); + gap: var(--gf-space-5); } .head { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; } h2 { - font-size: var(--gf-font-size-lg); + font-size: var(--gf-font-size-xl); margin: 0; - } - h3 { - font-size: var(--gf-font-size-md); - margin: 0 0 var(--gf-space-3); - } - .export { - font-family: var(--gf-font-family); - font-size: var(--gf-font-size-sm); - padding: var(--gf-space-2) var(--gf-space-4); - border-radius: var(--gf-radius-sm); - border: 1px solid var(--gf-color-accent); - background: var(--gf-color-surface); - color: var(--gf-color-accent); - cursor: pointer; + letter-spacing: var(--gf-tracking-tight); } .empty { - color: var(--gf-color-text-muted); + color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); } diff --git a/frontend/apps/rd-console/src/screens/SetupWizard.svelte b/frontend/apps/rd-console/src/screens/SetupWizard.svelte index ba23fb7..b3bba62 100644 --- a/frontend/apps/rd-console/src/screens/SetupWizard.svelte +++ b/frontend/apps/rd-console/src/screens/SetupWizard.svelte @@ -200,61 +200,94 @@ .wizard { display: flex; flex-direction: column; - gap: var(--gf-space-4); + gap: var(--gf-space-5); + max-width: 48rem; } .steps { display: flex; + flex-wrap: wrap; gap: var(--gf-space-2); list-style: none; margin: 0; padding: 0; + counter-reset: step; } .steps li button { - background: var(--gf-color-surface); - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-sm); - padding: var(--gf-space-1) var(--gf-space-3); + display: inline-flex; + align-items: center; + gap: var(--gf-space-2); + background: var(--gf-surface-alt); + border: 1px solid var(--gf-border-subtle); + border-radius: var(--gf-radius-pill); + padding: var(--gf-space-2) var(--gf-space-4); + font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); - color: var(--gf-color-text); + color: var(--gf-text-muted); cursor: pointer; + transition: + color var(--gf-motion-fast) var(--gf-ease-out), + background var(--gf-motion-fast) var(--gf-ease-out), + border-color var(--gf-motion-fast) var(--gf-ease-out); + } + .steps li button:hover { + color: var(--gf-text); } .steps li.active button { - border-color: var(--gf-color-accent); - color: var(--gf-color-accent); - font-weight: var(--gf-font-weight-bold); + border-color: var(--gf-accent); + background: var(--gf-accent-soft); + color: var(--gf-accent); + font-weight: var(--gf-font-weight-semibold); } .steps li.done button { - color: var(--gf-color-text-muted); + color: var(--gf-text-secondary); + border-color: var(--gf-border); } .panel { display: flex; flex-direction: column; - gap: var(--gf-space-3); - padding: var(--gf-space-4); - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-md); - background: var(--gf-color-surface); + gap: var(--gf-space-4); + padding: var(--gf-space-6); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + background: var(--gf-elevated); + box-shadow: var(--gf-shadow-xs); } h3 { margin: 0; - font-size: var(--gf-font-size-md); + font-size: var(--gf-font-size-lg); + font-weight: var(--gf-font-weight-semibold); + letter-spacing: var(--gf-tracking-tight); } label { display: flex; flex-direction: column; - gap: var(--gf-space-1); - font-size: var(--gf-font-size-sm); + gap: var(--gf-space-2); + font-size: var(--gf-font-size-xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + color: var(--gf-text-muted); max-width: 24rem; } input, select { font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); - padding: var(--gf-space-1) var(--gf-space-2); - border: 1px solid var(--gf-color-border); + font-weight: var(--gf-font-weight-normal); + text-transform: none; + letter-spacing: normal; + height: 2.25rem; + padding: 0 var(--gf-space-3); + border: 1px solid var(--gf-border); border-radius: var(--gf-radius-sm); - background: var(--gf-color-surface); - color: var(--gf-color-text); + background: var(--gf-surface-sunken); + color: var(--gf-text); + } + input:focus, + select:focus { + outline: none; + border-color: var(--gf-accent); + box-shadow: 0 0 0 3px var(--gf-accent-soft); } .class-row { display: flex; @@ -262,53 +295,79 @@ align-items: center; } fieldset { - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-sm); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + padding: var(--gf-space-4); display: flex; gap: var(--gf-space-4); align-items: end; flex-wrap: wrap; } legend { - font-weight: var(--gf-font-weight-bold); + font-weight: var(--gf-font-weight-semibold); font-size: var(--gf-font-size-sm); + padding: 0 var(--gf-space-2); } .review dt { - font-weight: var(--gf-font-weight-bold); - font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + font-size: var(--gf-font-size-2xs); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + color: var(--gf-text-muted); } .review dd { - margin: 0 0 var(--gf-space-2); + margin: var(--gf-space-1) 0 var(--gf-space-3); font-size: var(--gf-font-size-sm); } + .review code { + color: var(--gf-text-muted); + font-family: var(--gf-font-mono); + } .muted { - color: var(--gf-color-text-muted); + color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); } .problems { - color: var(--gf-color-danger); + color: var(--gf-danger); font-size: var(--gf-font-size-sm); } button { font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); - padding: var(--gf-space-2) var(--gf-space-4); + font-weight: var(--gf-font-weight-semibold); + height: 2.25rem; + padding: 0 var(--gf-space-4); border-radius: var(--gf-radius-sm); - border: 1px solid var(--gf-color-border); - background: var(--gf-color-surface); - color: var(--gf-color-text); + border: 1px solid var(--gf-border); + background: var(--gf-elevated); + color: var(--gf-text); cursor: pointer; + transition: + background var(--gf-motion-fast) var(--gf-ease-out), + border-color var(--gf-motion-fast) var(--gf-ease-out); + } + button:hover:not(:disabled) { + background: var(--gf-elevated-hover); + border-color: var(--gf-border-strong); + } + button:focus-visible { + outline: none; + box-shadow: var(--gf-focus-ring); } button:disabled { - opacity: 0.45; + opacity: 0.5; cursor: not-allowed; } .finish { - border-color: var(--gf-color-accent); - background: var(--gf-color-accent); - color: var(--gf-color-accent-contrast); + border-color: var(--gf-accent); + background: var(--gf-accent); + color: var(--gf-accent-contrast); align-self: flex-start; } + .finish:hover:not(:disabled) { + background: var(--gf-accent-hover); + border-color: var(--gf-accent-hover); + } .nav { display: flex; gap: var(--gf-space-3); diff --git a/frontend/packages/components/src/BracketTree.svelte b/frontend/packages/components/src/BracketTree.svelte index 1ce16e2..0c87897 100644 --- a/frontend/packages/components/src/BracketTree.svelte +++ b/frontend/packages/components/src/BracketTree.svelte @@ -40,7 +40,7 @@ display: flex; gap: var(--gf-space-6); align-items: stretch; - color: var(--gf-color-text); + color: var(--gf-text); font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); overflow-x: auto; @@ -50,40 +50,44 @@ flex-direction: column; justify-content: space-around; gap: var(--gf-space-4); - min-width: 9rem; + min-width: 10rem; } .round-name { margin: 0; - color: var(--gf-color-text-muted); - font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: var(--gf-tracking-caps); } .match { - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-sm); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); overflow: hidden; - background: var(--gf-color-surface); + background: var(--gf-elevated); + box-shadow: var(--gf-shadow-xs); } .slot { display: flex; align-items: center; justify-content: space-between; gap: var(--gf-space-2); - padding: var(--gf-space-2) var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-3); + transition: background var(--gf-motion-fast) var(--gf-ease-out); } .slot + .slot { - border-top: 1px solid var(--gf-color-border); + border-top: 1px solid var(--gf-border-subtle); } .slot.winner { - background: var(--gf-color-surface-alt); + background: var(--gf-accent-soft); font-weight: var(--gf-font-weight-bold); + box-shadow: inset 2px 0 0 var(--gf-accent); } .slot.winner .competitor { - color: var(--gf-color-leader); + color: var(--gf-accent); } .check { - color: var(--gf-color-live); + color: var(--gf-accent); font-weight: var(--gf-font-weight-bold); } diff --git a/frontend/packages/components/src/HeatSheet.svelte b/frontend/packages/components/src/HeatSheet.svelte index 553c8b3..ecf72ea 100644 --- a/frontend/packages/components/src/HeatSheet.svelte +++ b/frontend/packages/components/src/HeatSheet.svelte @@ -50,36 +50,61 @@ diff --git a/frontend/packages/components/src/Leaderboard.svelte b/frontend/packages/components/src/Leaderboard.svelte index 816ce35..1204adb 100644 --- a/frontend/packages/components/src/Leaderboard.svelte +++ b/frontend/packages/components/src/Leaderboard.svelte @@ -42,59 +42,71 @@ .gridfpv-leaderboard { border-collapse: collapse; width: 100%; - color: var(--gf-color-text); - background: var(--gf-color-surface); + color: var(--gf-text); + background: var(--gf-elevated); font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); } th, td { - padding: var(--gf-space-2) var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-3); text-align: left; } thead th { - color: var(--gf-color-text-muted); - font-weight: var(--gf-font-weight-medium); - font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + font-weight: var(--gf-font-weight-semibold); + font-size: var(--gf-font-size-2xs); text-transform: uppercase; - letter-spacing: 0.04em; - border-bottom: 1px solid var(--gf-color-border); + letter-spacing: var(--gf-tracking-caps); + border-bottom: 1px solid var(--gf-border); + } + tbody tr { + transition: background var(--gf-motion-fast) var(--gf-ease-out); + } + tbody tr:hover { + background: var(--gf-accent-soft); } tbody tr + tr td { - border-top: 1px solid var(--gf-color-border); + border-top: 1px solid var(--gf-border-subtle); } .pos { - width: 2.5em; + width: 2.75em; } .laps, .metric { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--gf-font-mono); + color: var(--gf-text-secondary); } .badge { display: inline-flex; align-items: center; justify-content: center; - min-width: 1.6em; - padding: 0 0.3em; + min-width: 1.7em; + height: 1.7em; + padding: 0 0.35em; border-radius: var(--gf-radius-sm); - background: var(--gf-color-surface-alt); + background: var(--gf-surface-alt); + color: var(--gf-text-secondary); font-weight: var(--gf-font-weight-bold); + font-variant-numeric: tabular-nums; } tr.medal[data-medal='gold'] .badge { background: var(--gf-color-gold); - color: #000; + color: var(--gf-medal-gold-contrast); + box-shadow: 0 0 12px -2px color-mix(in srgb, var(--gf-color-gold) 70%, transparent); } tr.medal[data-medal='silver'] .badge { background: var(--gf-color-silver); - color: #000; + color: var(--gf-medal-silver-contrast); } tr.medal[data-medal='bronze'] .badge { background: var(--gf-color-bronze); - color: #fff; + color: var(--gf-medal-bronze-contrast); } .pilot { - font-weight: var(--gf-font-weight-medium); + font-weight: var(--gf-font-weight-semibold); + letter-spacing: var(--gf-tracking-tight); } diff --git a/frontend/packages/components/src/PilotCard.svelte b/frontend/packages/components/src/PilotCard.svelte index 9350fae..466a21a 100644 --- a/frontend/packages/components/src/PilotCard.svelte +++ b/frontend/packages/components/src/PilotCard.svelte @@ -46,31 +46,41 @@ align-items: center; gap: var(--gf-space-3); padding: var(--gf-space-3) var(--gf-space-4); - background: var(--gf-color-surface); - border: 1px solid var(--gf-color-border); - border-radius: var(--gf-radius-md); - color: var(--gf-color-text); + background: var(--gf-elevated); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-lg); + color: var(--gf-text); font-family: var(--gf-font-family); + box-shadow: var(--gf-shadow-xs); + transition: + border-color var(--gf-motion-fast) var(--gf-ease-out), + transform var(--gf-motion-fast) var(--gf-ease-out); + } + .gridfpv-pilot-card:hover { + border-color: var(--gf-border-strong); } .position { display: inline-flex; align-items: center; justify-content: center; - min-width: 1.8em; - min-height: 1.8em; - border-radius: var(--gf-radius-sm); - background: var(--gf-color-leader); - color: var(--gf-color-accent-contrast); + min-width: 2em; + min-height: 2em; + border-radius: var(--gf-radius-md); + background: var(--gf-accent); + color: var(--gf-accent-contrast); font-weight: var(--gf-font-weight-bold); font-size: var(--gf-font-size-lg); + font-variant-numeric: tabular-nums; + box-shadow: 0 0 16px -4px var(--gf-accent-ring); } .body { flex: 1; } .name { display: block; - font-weight: var(--gf-font-weight-medium); + font-weight: var(--gf-font-weight-semibold); font-size: var(--gf-font-size-md); + letter-spacing: var(--gf-tracking-tight); } .stats { display: flex; @@ -80,17 +90,20 @@ .stat { display: flex; flex-direction: column; + gap: 2px; } dt { - color: var(--gf-color-text-muted); - font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + font-size: var(--gf-font-size-2xs); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: var(--gf-tracking-caps); + font-weight: var(--gf-font-weight-semibold); } dd { margin: 0; font-family: var(--gf-font-mono); font-variant-numeric: tabular-nums; font-weight: var(--gf-font-weight-medium); + color: var(--gf-text-secondary); } diff --git a/frontend/packages/components/src/RaceClock.svelte b/frontend/packages/components/src/RaceClock.svelte index 199307c..82c2e5a 100644 --- a/frontend/packages/components/src/RaceClock.svelte +++ b/frontend/packages/components/src/RaceClock.svelte @@ -29,14 +29,16 @@ diff --git a/frontend/packages/components/src/StandingsTable.svelte b/frontend/packages/components/src/StandingsTable.svelte index 321709f..5524d94 100644 --- a/frontend/packages/components/src/StandingsTable.svelte +++ b/frontend/packages/components/src/StandingsTable.svelte @@ -41,59 +41,73 @@ .gridfpv-standings { border-collapse: collapse; width: 100%; - color: var(--gf-color-text); - background: var(--gf-color-surface); + color: var(--gf-text); + background: var(--gf-elevated); font-family: var(--gf-font-family); font-size: var(--gf-font-size-sm); } caption { text-align: left; - padding: var(--gf-space-2) var(--gf-space-3); - color: var(--gf-color-text-muted); - font-weight: var(--gf-font-weight-medium); + padding: var(--gf-space-3) var(--gf-space-3); + color: var(--gf-text-muted); + font-weight: var(--gf-font-weight-semibold); + font-size: var(--gf-font-size-xs); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); } th, td { - padding: var(--gf-space-2) var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-3); text-align: left; } thead th { - color: var(--gf-color-text-muted); - font-weight: var(--gf-font-weight-medium); - font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + font-weight: var(--gf-font-weight-semibold); + font-size: var(--gf-font-size-2xs); text-transform: uppercase; - letter-spacing: 0.04em; - border-bottom: 1px solid var(--gf-color-border); + letter-spacing: var(--gf-tracking-caps); + border-bottom: 1px solid var(--gf-border); + } + tbody tr { + transition: background var(--gf-motion-fast) var(--gf-ease-out); + } + tbody tr:hover { + background: var(--gf-accent-soft); } tbody tr + tr td { - border-top: 1px solid var(--gf-color-border); + border-top: 1px solid var(--gf-border-subtle); } .pos { - width: 2.5em; + width: 2.75em; } .badge { display: inline-flex; align-items: center; justify-content: center; - min-width: 1.6em; - padding: 0 0.3em; + min-width: 1.7em; + height: 1.7em; + padding: 0 0.35em; border-radius: var(--gf-radius-sm); - background: var(--gf-color-surface-alt); + background: var(--gf-surface-alt); + color: var(--gf-text-secondary); font-weight: var(--gf-font-weight-bold); + font-variant-numeric: tabular-nums; } tr.medal[data-medal='gold'] .badge { background: var(--gf-color-gold); - color: #000; + color: var(--gf-medal-gold-contrast); + box-shadow: 0 0 12px -2px color-mix(in srgb, var(--gf-color-gold) 70%, transparent); } tr.medal[data-medal='silver'] .badge { background: var(--gf-color-silver); - color: #000; + color: var(--gf-medal-silver-contrast); } tr.medal[data-medal='bronze'] .badge { background: var(--gf-color-bronze); - color: #fff; + color: var(--gf-medal-bronze-contrast); } .pilot { - font-weight: var(--gf-font-weight-medium); + font-weight: var(--gf-font-weight-semibold); + letter-spacing: var(--gf-tracking-tight); } diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts index 21031e0..8b7191d 100644 --- a/frontend/packages/components/src/index.ts +++ b/frontend/packages/components/src/index.ts @@ -15,7 +15,7 @@ * import '@gridfpv/components/tokens.css'; */ -// Components +// Race-domain components export { default as Leaderboard } from './Leaderboard.svelte'; export { default as StandingsTable } from './StandingsTable.svelte'; export { default as BracketTree } from './BracketTree.svelte'; @@ -23,6 +23,21 @@ export { default as HeatSheet } from './HeatSheet.svelte'; export { default as RaceClock } from './RaceClock.svelte'; export { default as PilotCard } from './PilotCard.svelte'; +// Base UI primitives (framework-pure, token-styled) — the design-system kit the +// whole console inherits (#71, Slice 0). See ./primitives/*. +export { default as Button } from './primitives/Button.svelte'; +export { default as Field } from './primitives/Field.svelte'; +export { default as Input } from './primitives/Input.svelte'; +export { default as Select } from './primitives/Select.svelte'; +export { default as Card } from './primitives/Card.svelte'; +export { default as Badge } from './primitives/Badge.svelte'; +export { default as StatusPill } from './primitives/StatusPill.svelte'; +export { default as Tabs } from './primitives/Tabs.svelte'; +export { default as Banner } from './primitives/Banner.svelte'; +export { default as Dialog } from './primitives/Dialog.svelte'; +export { default as ToastHost } from './primitives/ToastHost.svelte'; +export { toast, toasts, type Toast, type ToastTone } from './primitives/toast.svelte.js'; + // Presentational helpers (pure, framework-agnostic) export { formatClock, formatMicros, formatMetric, medalFor } from './format.js'; diff --git a/frontend/packages/components/src/primitives/Badge.svelte b/frontend/packages/components/src/primitives/Badge.svelte new file mode 100644 index 0000000..52dacb4 --- /dev/null +++ b/frontend/packages/components/src/primitives/Badge.svelte @@ -0,0 +1,89 @@ + + + + {#if dot}{/if} + {@render children()} + + + diff --git a/frontend/packages/components/src/primitives/Banner.svelte b/frontend/packages/components/src/primitives/Banner.svelte new file mode 100644 index 0000000..4549a29 --- /dev/null +++ b/frontend/packages/components/src/primitives/Banner.svelte @@ -0,0 +1,115 @@ + + +
      + +
      + {#if title}{title}{/if} + {@render children()} +
      + {#if actions}
      {@render actions()}
      {/if} + {#if ondismiss} + + {/if} +
      + + diff --git a/frontend/packages/components/src/primitives/Button.svelte b/frontend/packages/components/src/primitives/Button.svelte new file mode 100644 index 0000000..3ff64b8 --- /dev/null +++ b/frontend/packages/components/src/primitives/Button.svelte @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/packages/components/src/primitives/Card.svelte b/frontend/packages/components/src/primitives/Card.svelte new file mode 100644 index 0000000..8625366 --- /dev/null +++ b/frontend/packages/components/src/primitives/Card.svelte @@ -0,0 +1,89 @@ + + +
      + {#if title || actions} +
      +
      + {#if title}

      {title}

      {/if} + {#if subtitle}

      {subtitle}

      {/if} +
      + {#if actions}
      {@render actions()}
      {/if} +
      + {/if} +
      + {@render children()} +
      +
      + + diff --git a/frontend/packages/components/src/primitives/Dialog.svelte b/frontend/packages/components/src/primitives/Dialog.svelte new file mode 100644 index 0000000..d39c96f --- /dev/null +++ b/frontend/packages/components/src/primitives/Dialog.svelte @@ -0,0 +1,155 @@ + + + { + if (!dismissable) e.preventDefault(); + }} + onclick={onBackdrop} +> +
      + {#if title || dismissable} +
      + {#if title}

      {title}

      {/if} + {#if dismissable} + + {/if} +
      + {/if} +
      {@render children()}
      + {#if footer}
      {@render footer()}
      {/if} +
      +
      + + diff --git a/frontend/packages/components/src/primitives/Field.svelte b/frontend/packages/components/src/primitives/Field.svelte new file mode 100644 index 0000000..3e5fd2a --- /dev/null +++ b/frontend/packages/components/src/primitives/Field.svelte @@ -0,0 +1,67 @@ + + +
      + {#if label} + + {label}{#if required}{/if} + + {/if} + {@render children()} + {#if error} + {error} + {:else if hint} + {hint} + {/if} +
      + + diff --git a/frontend/packages/components/src/primitives/Input.svelte b/frontend/packages/components/src/primitives/Input.svelte new file mode 100644 index 0000000..7cdbbfa --- /dev/null +++ b/frontend/packages/components/src/primitives/Input.svelte @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/packages/components/src/primitives/Select.svelte b/frontend/packages/components/src/primitives/Select.svelte new file mode 100644 index 0000000..064a551 --- /dev/null +++ b/frontend/packages/components/src/primitives/Select.svelte @@ -0,0 +1,80 @@ + + +
      + + +
      + + diff --git a/frontend/packages/components/src/primitives/StatusPill.svelte b/frontend/packages/components/src/primitives/StatusPill.svelte new file mode 100644 index 0000000..292633a --- /dev/null +++ b/frontend/packages/components/src/primitives/StatusPill.svelte @@ -0,0 +1,149 @@ + + + + + {text} + + + diff --git a/frontend/packages/components/src/primitives/Tabs.svelte b/frontend/packages/components/src/primitives/Tabs.svelte new file mode 100644 index 0000000..b0c1edf --- /dev/null +++ b/frontend/packages/components/src/primitives/Tabs.svelte @@ -0,0 +1,109 @@ + + +
      + {#each items as item (item.id)} + {@const active = item.id === value} + + {/each} +
      + + diff --git a/frontend/packages/components/src/primitives/ToastHost.svelte b/frontend/packages/components/src/primitives/ToastHost.svelte new file mode 100644 index 0000000..9c3f6b4 --- /dev/null +++ b/frontend/packages/components/src/primitives/ToastHost.svelte @@ -0,0 +1,124 @@ + + +
      + {#each toasts.items as t (t.id)} +
      + +
      + {#if t.title}{t.title}{/if} + {t.message} +
      + +
      + {/each} +
      + + diff --git a/frontend/packages/components/src/primitives/toast.svelte.ts b/frontend/packages/components/src/primitives/toast.svelte.ts new file mode 100644 index 0000000..9dafd1f --- /dev/null +++ b/frontend/packages/components/src/primitives/toast.svelte.ts @@ -0,0 +1,48 @@ +/** + * Toast store — a tiny global queue of transient notifications, rendered by + * `ToastHost`. Framework-pure (Svelte 5 runes), no protocol dependency. Any + * surface calls `toasts.push(...)` (or the `toast.success/error/...` helpers) + * and mounts one ``; the host auto-dismisses after `duration`. + */ + +export type ToastTone = 'info' | 'success' | 'warn' | 'danger'; + +export interface Toast { + id: number; + tone: ToastTone; + title?: string; + message: string; + /** Auto-dismiss after this many ms; 0 keeps it until dismissed. */ + duration: number; +} + +let seq = 0; + +class ToastStore { + items = $state([]); + + push(t: Omit & { duration?: number }): number { + const id = ++seq; + this.items = [...this.items, { id, duration: t.duration ?? 4000, ...t }]; + return id; + } + + dismiss(id: number): void { + this.items = this.items.filter((t) => t.id !== id); + } + + clear(): void { + this.items = []; + } +} + +export const toasts = new ToastStore(); + +/** Convenience helpers for the four tones. */ +export const toast = { + info: (message: string, title?: string) => toasts.push({ tone: 'info', message, title }), + success: (message: string, title?: string) => toasts.push({ tone: 'success', message, title }), + warn: (message: string, title?: string) => toasts.push({ tone: 'warn', message, title }), + error: (message: string, title?: string) => + toasts.push({ tone: 'danger', message, title, duration: 6000 }) +}; diff --git a/frontend/packages/components/src/tokens.css b/frontend/packages/components/src/tokens.css index a9d19ca..6c24d64 100644 --- a/frontend/packages/components/src/tokens.css +++ b/frontend/packages/components/src/tokens.css @@ -1,71 +1,295 @@ /** - * GridFPV design tokens — one token set, three contexts (docs/clients.html §5). + * GridFPV design tokens — the single source of truth for the whole console + * (docs/clients.html §5: one token set, themed per surface/context). * - * Every component styles itself only through these CSS custom properties, so a - * surface re-themes the whole library by overriding tokens on a wrapping element - * (or `:root`) — no per-component CSS overrides. The defaults below are the - * "RD console" baseline (readable on a light surface); the `.gridfpv-overlay` - * and `.gridfpv-dense` context classes below re-point the same tokens for the - * OBS overlay (transparent, large, high-contrast) and dense-table contexts. + * This is the v0.4 "design foundation" (#71, Slice 0): a modern, dark-mode-first + * race-operations look in the spirit of Linear / Vercel / live-sports broadcast + * graphics. Every component and screen styles itself ONLY through these CSS + * custom properties — no hard-coded colors, sizes, or shadows anywhere else — so + * a surface re-themes the entire library by overriding tokens on a wrapping + * element (`.gridfpv-root`) or `:root`. + * + * Themes & contexts: + * :root / .gridfpv-root the DARK baseline (the default console look) + * .theme-light / [data-theme] the light override of the same semantic tokens + * .gridfpv-overlay broadcast keyer (transparent, oversized text) + * .gridfpv-dense tighter spacing + type for data-dense tables * * Token groups: - * --gf-color-* palette (surface, text, accent, position medals, state) - * --gf-space-* spacing scale (4px base) - * --gf-font-* type family + size scale + weights - * --gf-radius-* corner radii + * --gf-color-* raw neutral + brand + accent ramps (rarely used directly) + * --gf-surface/… SEMANTIC color roles (surface, elevated, border, text, …) + * --gf-phase-* the six heat-phase colors (Scheduled→…→Scored) + * --gf-conn-* connection-state colors + * --gf-space-* spacing scale (4px base) + * --gf-font-* type family + size scale + weights + line-heights + * --gf-radius-* corner radii + * --gf-shadow-* elevation / shadow ramp + * --gf-ring-* focus rings + * --gf-motion-* durations + easings * - * Import this once per surface (e.g. in an app's entry CSS): + * Import once per surface (e.g. an app's entry): * import '@gridfpv/components/tokens.css'; */ +/* ── Webfont: Inter (UI) — offline-friendly, variable. ────────────────────── + * Maps a stable family name to a locally-installed Inter if present, falling + * back to a clean system grotesk stack so the console stays legible without the + * bundled font. Inter-class grotesk is the modern-console look the brief asks + * for; no network fetch, so it stays offline-friendly. */ +@font-face { + font-family: 'GridFPV Sans'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter'), local('Inter Variable'), local('InterVariable'); +} + :where(.gridfpv-root), :root { - /* ── Palette ─────────────────────────────────────────────────────────── */ - --gf-color-surface: #ffffff; - --gf-color-surface-alt: #f4f5f7; - --gf-color-border: #d9dce1; - --gf-color-text: #1a1d21; - --gf-color-text-muted: #6b7280; - --gf-color-accent: #2563eb; - --gf-color-accent-contrast: #ffffff; - - /* Position / medal colors, used by leaderboards + standings. */ - --gf-color-gold: #d4af37; - --gf-color-silver: #9aa0a6; - --gf-color-bronze: #b06a2c; + /* ── Brand & accent ramp ───────────────────────────────────────────────── + * GridFPV brand accent: an electric "grid green" — the signal/aliveness of a + * live race feed. Distinct from the phase/state greens (those skew toward the + * stoplight semantics); the brand is cooler and more saturated. */ + --gf-brand-300: #6ff7c8; + --gf-brand-400: #34e6a8; + --gf-brand-500: #10d191; /* primary brand accent */ + --gf-brand-600: #06a874; + --gf-brand-700: #097a57; + --gf-brand-contrast: #04140f; + + /* Neutral ramp — cool, slightly blue-tinted slate (a broadcast-console look, + * not a flat gray). 0 = deepest background, 1000 = brightest text. */ + --gf-neutral-0: #0a0d12; + --gf-neutral-50: #0e1218; + --gf-neutral-100: #131820; + --gf-neutral-150: #181e28; + --gf-neutral-200: #1d2531; + --gf-neutral-300: #28323f; + --gf-neutral-400: #3a4658; + --gf-neutral-500: #55647a; + --gf-neutral-600: #788699; + --gf-neutral-700: #9aa6b6; + --gf-neutral-800: #c2cad4; + --gf-neutral-900: #e4e8ee; + --gf-neutral-1000: #f7f9fb; + + /* Shared accent hues used by semantic state roles below. */ + --gf-blue-400: #4b93ff; + --gf-blue-500: #2f7df0; + --gf-green-400: #2fd87a; + --gf-green-500: #18b765; + --gf-amber-400: #f7b545; + --gf-amber-500: #e09316; + --gf-red-400: #ff6b6b; + --gf-red-500: #e84545; + --gf-violet-400: #9d7bff; + --gf-violet-500: #7c54f0; + --gf-cyan-400: #38d6e6; + + /* ── Semantic surface / text roles (DARK baseline) ─────────────────────── + * These are what components actually consume. `surface` is the app canvas, + * `elevated` a raised card/panel, `overlay` a popover/dialog plane. */ + --gf-surface: var(--gf-neutral-50); + --gf-surface-sunken: var(--gf-neutral-0); + --gf-surface-alt: var(--gf-neutral-100); + --gf-elevated: var(--gf-neutral-150); + --gf-elevated-hover: var(--gf-neutral-200); + --gf-overlay: var(--gf-neutral-200); + --gf-border: var(--gf-neutral-300); + --gf-border-strong: var(--gf-neutral-400); + --gf-border-subtle: var(--gf-neutral-200); + + --gf-text: var(--gf-neutral-1000); + --gf-text-secondary: var(--gf-neutral-800); + --gf-text-muted: var(--gf-neutral-600); + --gf-text-faint: var(--gf-neutral-500); + --gf-text-on-accent: var(--gf-brand-contrast); - /* State colors (live phase, win/leader, penalty/DQ). */ - --gf-color-live: #16a34a; - --gf-color-leader: #2563eb; - --gf-color-warn: #b45309; - --gf-color-danger: #dc2626; + /* Brand accent as semantic roles. */ + --gf-accent: var(--gf-brand-500); + --gf-accent-hover: var(--gf-brand-400); + --gf-accent-active: var(--gf-brand-600); + --gf-accent-contrast: var(--gf-brand-contrast); + --gf-accent-soft: color-mix(in srgb, var(--gf-brand-500) 16%, transparent); + --gf-accent-ring: color-mix(in srgb, var(--gf-brand-500) 45%, transparent); - /* ── Spacing scale (4px base) ────────────────────────────────────────── */ + /* Back-compat aliases for the previous flat token names. Existing components + * referencing `--gf-color-*` keep working while everything migrates to the + * semantic roles above. New code should use the role tokens. */ + --gf-color-surface: var(--gf-surface); + --gf-color-surface-alt: var(--gf-surface-alt); + --gf-color-border: var(--gf-border); + --gf-color-text: var(--gf-text); + --gf-color-text-muted: var(--gf-text-muted); + --gf-color-accent: var(--gf-accent); + --gf-color-accent-contrast: var(--gf-accent-contrast); + + /* ── Position / medal colors (leaderboards + standings). ─────────────────── */ + --gf-color-gold: #f5c542; + --gf-color-silver: #c2cad4; + --gf-color-bronze: #d08a4a; + --gf-medal-gold-contrast: #2a1e00; + --gf-medal-silver-contrast: #14181f; + --gf-medal-bronze-contrast: #1a0e02; + + /* ── Semantic status colors (success / warning / danger / info). ─────────── */ + --gf-success: var(--gf-green-400); + --gf-success-soft: color-mix(in srgb, var(--gf-green-400) 16%, transparent); + --gf-warn: var(--gf-amber-400); + --gf-warn-soft: color-mix(in srgb, var(--gf-amber-400) 16%, transparent); + --gf-danger: var(--gf-red-400); + --gf-danger-soft: color-mix(in srgb, var(--gf-red-400) 16%, transparent); + --gf-info: var(--gf-blue-400); + --gf-info-soft: color-mix(in srgb, var(--gf-blue-400) 16%, transparent); + + /* Legacy state aliases. */ + --gf-color-live: var(--gf-success); + --gf-color-leader: var(--gf-accent); + --gf-color-warn: var(--gf-warn); + --gf-color-danger: var(--gf-danger); + + /* ── Heat-phase colors (Scheduled→Staged→Armed→Running→Finished→Scored). ─── + * A deliberate progression: neutral → warming amber → hot red (armed/ready) → + * live brand-green (running) → cool blue (finished) → settled violet (scored). */ + --gf-phase-scheduled: var(--gf-neutral-600); + --gf-phase-staged: var(--gf-amber-400); + --gf-phase-armed: var(--gf-red-400); + --gf-phase-running: var(--gf-brand-500); + --gf-phase-finished: var(--gf-blue-400); + --gf-phase-scored: var(--gf-violet-400); + + /* ── Connection-state colors (live / connecting / reconnecting / closed). ─── */ + --gf-conn-live: var(--gf-success); + --gf-conn-pending: var(--gf-info); + --gf-conn-warn: var(--gf-warn); + --gf-conn-idle: var(--gf-text-faint); + + /* ── Spacing scale (4px base) ────────────────────────────────────────────── */ + --gf-space-0: 0; --gf-space-1: 0.25rem; --gf-space-2: 0.5rem; --gf-space-3: 0.75rem; --gf-space-4: 1rem; + --gf-space-5: 1.25rem; --gf-space-6: 1.5rem; --gf-space-8: 2rem; + --gf-space-10: 2.5rem; + --gf-space-12: 3rem; - /* ── Typography ──────────────────────────────────────────────────────── */ - --gf-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --gf-font-mono: ui-monospace, 'SF Mono', 'Roboto Mono', monospace; + /* ── Typography ──────────────────────────────────────────────────────────── + * UI is a grotesk (Inter / system fallback); numeric/timing readouts use a + * tabular mono so columns stay aligned. */ + --gf-font-family: 'GridFPV Sans', Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --gf-font-mono: + 'JetBrains Mono', ui-monospace, 'SF Mono', 'Roboto Mono', 'Cascadia Code', monospace; + --gf-font-size-2xs: 0.6875rem; --gf-font-size-xs: 0.75rem; - --gf-font-size-sm: 0.875rem; - --gf-font-size-md: 1rem; - --gf-font-size-lg: 1.25rem; - --gf-font-size-xl: 1.75rem; - --gf-font-size-2xl: 2.5rem; + --gf-font-size-sm: 0.8125rem; + --gf-font-size-md: 0.9375rem; + --gf-font-size-lg: 1.125rem; + --gf-font-size-xl: 1.5rem; + --gf-font-size-2xl: 2rem; + --gf-font-size-3xl: 2.75rem; --gf-font-weight-normal: 400; - --gf-font-weight-medium: 600; + --gf-font-weight-medium: 500; + --gf-font-weight-semibold: 600; --gf-font-weight-bold: 700; - /* ── Radii ───────────────────────────────────────────────────────────── */ - --gf-radius-sm: 4px; - --gf-radius-md: 8px; + --gf-line-tight: 1.15; + --gf-line-snug: 1.3; + --gf-line-normal: 1.5; + + --gf-tracking-tight: -0.01em; + --gf-tracking-normal: 0; + --gf-tracking-wide: 0.04em; + --gf-tracking-caps: 0.06em; + + /* ── Radii ─────────────────────────────────────────────────────────────── */ + --gf-radius-xs: 4px; + --gf-radius-sm: 6px; + --gf-radius-md: 10px; + --gf-radius-lg: 14px; + --gf-radius-xl: 20px; + --gf-radius-pill: 999px; + + /* ── Elevation / shadows (tuned for a dark canvas). ──────────────────────── */ + --gf-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --gf-shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.45); + --gf-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5); + --gf-shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.6); + --gf-shadow-glow: 0 0 0 1px var(--gf-accent-ring), 0 0 24px -4px var(--gf-accent-ring); + --gf-shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.03); + + /* ── Focus rings ─────────────────────────────────────────────────────────── */ + --gf-ring-width: 2px; + --gf-ring-offset: 2px; + --gf-ring-color: var(--gf-accent-ring); + --gf-focus-ring: + 0 0 0 var(--gf-ring-offset) var(--gf-surface), + 0 0 0 calc(var(--gf-ring-offset) + var(--gf-ring-width)) var(--gf-accent); + + /* ── Motion ─────────────────────────────────────────────────────────────── */ + --gf-motion-fast: 120ms; + --gf-motion-base: 200ms; + --gf-motion-slow: 320ms; + --gf-ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --gf-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --gf-ease-spring: cubic-bezier(0.22, 1.2, 0.36, 1); +} + +/** + * Light theme — re-points the SEMANTIC roles only (the brand/accent stays the + * same hue family). Apply `class="theme-light"` or `data-theme="light"` on a + * wrapping element (or `:root`). The dense/overlay contexts compose on top. + */ +:where(.theme-light), +:where([data-theme='light']) { + --gf-surface: #ffffff; + --gf-surface-sunken: #eef1f5; + --gf-surface-alt: #f4f6f9; + --gf-elevated: #ffffff; + --gf-elevated-hover: #f4f6f9; + --gf-overlay: #ffffff; + --gf-border: #dce0e6; + --gf-border-strong: #c3c9d2; + --gf-border-subtle: #eaedf1; + + --gf-text: #0c1118; + --gf-text-secondary: #2c3440; + --gf-text-muted: #5d6775; + --gf-text-faint: #8a93a1; + --gf-text-on-accent: var(--gf-brand-contrast); + + --gf-accent: var(--gf-brand-600); + --gf-accent-hover: var(--gf-brand-500); + --gf-accent-active: var(--gf-brand-700); + --gf-accent-soft: color-mix(in srgb, var(--gf-brand-600) 12%, transparent); + --gf-accent-ring: color-mix(in srgb, var(--gf-brand-600) 40%, transparent); + + --gf-success: var(--gf-green-500); + --gf-warn: var(--gf-amber-500); + --gf-danger: var(--gf-red-500); + --gf-info: var(--gf-blue-500); + + --gf-phase-scheduled: var(--gf-neutral-500); + --gf-phase-staged: var(--gf-amber-500); + --gf-phase-armed: var(--gf-red-500); + --gf-phase-running: var(--gf-brand-600); + --gf-phase-finished: var(--gf-blue-500); + --gf-phase-scored: var(--gf-violet-500); + + --gf-color-gold: #d4af37; + --gf-color-silver: #9aa0a6; + --gf-color-bronze: #b06a2c; + --gf-medal-silver-contrast: #14181f; + + --gf-shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.06); + --gf-shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.1), 0 1px 2px rgba(16, 24, 40, 0.06); + --gf-shadow-md: 0 8px 24px rgba(16, 24, 40, 0.12); + --gf-shadow-lg: 0 18px 48px rgba(16, 24, 40, 0.16); + --gf-shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.6); } /** @@ -73,18 +297,30 @@ * broadcast keyer. Apply `class="gridfpv-overlay"` on a wrapping element. */ :where(.gridfpv-overlay) { - --gf-color-surface: transparent; - --gf-color-surface-alt: rgba(0, 0, 0, 0.35); - --gf-color-border: rgba(255, 255, 255, 0.25); - --gf-color-text: #ffffff; - --gf-color-text-muted: rgba(255, 255, 255, 0.7); - --gf-color-accent: #38bdf8; + --gf-surface: transparent; + --gf-surface-alt: rgba(0, 0, 0, 0.4); + --gf-elevated: rgba(0, 0, 0, 0.5); + --gf-border: rgba(255, 255, 255, 0.25); + --gf-text: #ffffff; + --gf-text-muted: rgba(255, 255, 255, 0.72); + --gf-accent: var(--gf-brand-400); + + --gf-color-surface: var(--gf-surface); + --gf-color-surface-alt: var(--gf-surface-alt); + --gf-color-border: var(--gf-border); + --gf-color-text: var(--gf-text); + --gf-color-text-muted: var(--gf-text-muted); + --gf-color-accent: var(--gf-accent); --gf-font-size-sm: 1rem; --gf-font-size-md: 1.25rem; --gf-font-size-lg: 1.75rem; --gf-font-size-xl: 2.5rem; - --gf-font-size-2xl: 3.5rem; + --gf-font-size-2xl: 3.25rem; + --gf-font-size-3xl: 4rem; + + --gf-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.6); + --gf-shadow-md: 0 4px 24px rgba(0, 0, 0, 0.7); } /** @@ -94,8 +330,19 @@ :where(.gridfpv-dense) { --gf-space-2: 0.375rem; --gf-space-3: 0.5rem; - --gf-space-4: 0.625rem; + --gf-space-4: 0.75rem; + --gf-space-6: 1.125rem; --gf-font-size-sm: 0.8125rem; --gf-font-size-md: 0.875rem; } + +/* Honor reduced-motion: collapse durations so transitions are instant. */ +@media (prefers-reduced-motion: reduce) { + :where(.gridfpv-root), + :root { + --gf-motion-fast: 0ms; + --gf-motion-base: 0ms; + --gf-motion-slow: 0ms; + } +} From 75537e70cf46f9aef55adb980512645b28e53b22 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 19:09:57 +0000 Subject: [PATCH 043/362] =?UTF-8?q?ci:=20GitHub=20Actions=20=E2=80=94=20Ru?= =?UTF-8?q?st=20(cargo=20xtask=20ci)=20+=20frontend=20build/check/lint/tes?= =?UTF-8?q?t/contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend + contract suite were never in the GitLab pipeline; on GitHub they run on every push/PR. Heavier browser-e2e + dockerized-RH live suite gated separately. Part of the GitHub migration. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c54e5be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +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@v4 + - 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 + + frontend: + name: Frontend — build/check/lint/test/contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + 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 From f9cc50410dacb7c6a5bdf2fb663250e7783c2e50 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 19:47:49 +0000 Subject: [PATCH 044/362] docs: event-as-container model + local/cloud storage realizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the decided Event-as-container data model and its two physical storage realizations in the internal design docs. architecture.html §4 (Conceptual data model) gains: - "The Event as aggregate root" — every fact belongs to exactly one event; per-event dense offsets; Practice as a built-in non-persistent event. - "One logical model, two storage realizations" — the backend-agnostic EventLog trait + EventRegistry as the seam; Realization 1 (local: per-event SQLite file, in-memory Practice); Realization 2 (cloud: shared Postgres with events + event_log tables, FK event_id, composite PK (event_id, offset), JSONB fact) with a SQL schema sketch. - "Why the scoping lives in storage, not the fact model" — the unifying principle that lets the v0.7 Cloud backend slot in untouched. cloud.html §3 and roadmap.html v0.7 gain forward-pointers to the container schema. style.css gains a pre.code block for the SQL sketch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/architecture.html | 121 +++++++++++++++++++++++++++++++++++++++++ docs/assets/style.css | 17 ++++++ docs/cloud.html | 6 +- docs/roadmap.html | 2 +- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/docs/architecture.html b/docs/architecture.html index 14cdb54..35f52cf 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -269,6 +269,127 @@

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

      + +

      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
      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/cloud.html b/docs/cloud.html index d199a3e..c5b7ee6 100644 --- a/docs/cloud.html +++ b/docs/cloud.html @@ -200,7 +200,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

      diff --git a/docs/roadmap.html b/docs/roadmap.html index 8d6bd26..3cca80b 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -198,7 +198,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.

      From 571d2ea7438199e64a8d81daceefd597faeedc9d Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 20:05:51 +0000 Subject: [PATCH 045/362] docs: client access/auth tiers + TLS posture (mandatory cloud TLS) (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the decided client access/auth model and TLS posture across the design docs: - Two access capabilities, not one. Control: loopback = trusted with NO auth (source-IP middleware, peer.ip().is_loopback(); safe because the OS drops spoofed-loopback martians); remote control opt-in, off by default, gated by an RD-set passphrase. No local auth UI; the old "RD token" becomes a remote-access passphrase setting, not a login. Read: a first-class, scoped tier (Scope::Pilot/event) every pilot/spectator client uses, open or join-token-gated on the LAN, backing the Cloud account read authorization. - TLS posture: plain HTTP acceptable on the trusted LAN now; TLS plan required for the v0.5 PWA/remote control so the passphrase never crosses untrusted WiFi in clear; mandatory, non-negotiable HTTPS/WSS end-to-end on the Cloud (v0.7) with zero unencrypted internet traffic, ever. Edits: protocol.html (§5 rewritten + two Open-decisions), architecture.html (§9 principles + mandatory-TLS callout), clients.html (console/mobile-control surfaces, table, Open-decisions), cloud.html (§7 read-tier + mandatory-TLS callout). Framed as the evolution of the prior RD-token + join-token notes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/architecture.html | 36 ++++++++-- docs/clients.html | 83 ++++++++++++++--------- docs/cloud.html | 23 +++++-- docs/protocol.html | 146 ++++++++++++++++++++++++++++++----------- 4 files changed, 208 insertions(+), 80 deletions(-) diff --git a/docs/architecture.html b/docs/architecture.html index 35f52cf..2411ebb 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -526,16 +526,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 @@ -546,9 +567,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.

      diff --git a/docs/clients.html b/docs/clients.html index 6c352b4..90c7c6a 100644 --- a/docs/clients.html +++ b/docs/clients.html @@ -59,7 +59,7 @@

      1. Three surfaces, one client

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

      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, designed to load inside the Tauri window (Architecture §2). The Tauri shell and 3-OS single-binary packaging are deferred (#57/#58); v0.4 ships the RD console as a web app served by the Director. It is the only surface that exercises the protocol's privileged write/control path — - arming heats, marshaling, configuring the pipeline — so it authenticates with the RD role - (Architecture §9; mechanics in Protocol). It is information-dense - and keyboard-friendly: a working tool for someone running a live event, not a viewer. + 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.

      +
      +

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

      +

      The console is "just a co-located web client" (Architecture §2): once the Tauri shell @@ -146,24 +157,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).

      @@ -172,10 +186,10 @@

      RD mobile-control surface — privileged, mobile, LAN-only (future) SurfaceDirectionAuthTransportShellEmphasis - RD consoleread + controlRD roleLAN (local)web app (Tauri deferred, #57/#58)dense, 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)web app (Tauri deferred, #57/#58)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 @@ -409,19 +423,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.
    2. -
    3. 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.
    4. +
    5. 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.
    6. Native push details (deferred). When the Capacitor wrap lands, how native push registration maps onto the Cloud push infrastructure — paired with Cloud & Season.
    7. -
    8. 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.
    9. +
    10. 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 c5b7ee6..dff5e6b 100644 --- a/docs/cloud.html +++ b/docs/cloud.html @@ -373,10 +373,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:

      diff --git a/docs/protocol.html b/docs/protocol.html index 50aa69a..65b4c49 100644 --- a/docs/protocol.html +++ b/docs/protocol.html @@ -319,53 +319,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

      +
      +

      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.

      +
      -

      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.

      +

      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

      -

      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.

      +

      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.

      @@ -484,15 +543,28 @@

      9. Open decisions

      Architecture §4. Exact window sizes are tuned at implementation. As built (v0.4): the retained window is 256 log offsets; an older cursor gets a ReSnapshotRequired / StaleCursor response. -
    • RESOLVED — Auth credential format: opaque bearer tokens (+ optional signed join-token). - RD and account auth use opaque bearer tokens backed by a server-side - session (easy to revoke), plus an optional lightweight signed join-token - (printed as a QR code, baked into the URL) granting read-only LAN access. Trust boundaries - are fixed in Architecture §9. Revisit JWT only if - stateless Cloud scaling later demands it. As built (v0.4): opaque 256-bit - bearer tokens in an in-memory revocable TokenStore, with roles Rd - and ReadOnly — reads are open, control is RD-gated, and the read-only join-token - grants read scope only.
    • +
    • RESOLVED — 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 From 807ecddaa7dd2c34f0423606045046c456d1a1dd Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 20:10:05 +0000 Subject: [PATCH 046/362] Events as first-class containers: EventRegistry + per-event logs + event-rooted routes (#72) Make an Event the container for the whole fact log. Introduce a backend-agnostic EventRegistry mapping each EventId to that event's own AppState (its own EventLog + append-notify, a shared Director-wide TokenStore). A built-in in-memory, non-persistent Practice event is always present; created events get a SQLite file per event under GRIDFPV_DATA_DIR. - crates/server/src/events.rs: EventRegistry, EventMeta, CreateEventRequest; auto-generated ids (name slug + CSPRNG suffix, never user-entered); Postgres-readiness note (one events table + event_log keyed by event_id with composite PK (event_id, offset) so offsets stay per-event dense). - Event-rooted routing: /events, POST /events (RD-gated), and the per-event surface under /events/{eventId}/{snapshot,stream,control,auth/join-token}. Handlers resolve eventId -> the event's log; unknown id -> typed 404 (UnknownScope). snapshot_event now serves the resolved event's log (no more whole-log passthrough). ControlAuth gates control via the shared token store. - Director: build the registry, seed Practice, configure the data dir; the sim source is now per-event (spawn_registry_bridge runs one bridge per event, attaching to events created at runtime), so Practice and any created event run sim races independently. - Regenerated ts-rs bindings for EventMeta + CreateEventRequest; gen-drift clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/CreateEventRequest.ts | 15 + bindings/EventMeta.ts | 31 ++ crates/app/src/director.rs | 38 ++- crates/app/src/main.rs | 60 ++-- crates/app/src/source.rs | 48 +++ crates/app/tests/director.rs | 15 +- crates/server/src/app.rs | 303 +++++++++++++------ crates/server/src/control_handler.rs | 47 +-- crates/server/src/events.rs | 421 +++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/ws.rs | 29 +- crates/server/tests/control.rs | 79 +++-- crates/server/tests/ws_stream.rs | 38 ++- 13 files changed, 925 insertions(+), 200 deletions(-) create mode 100644 bindings/CreateEventRequest.ts create mode 100644 bindings/EventMeta.ts create mode 100644 crates/server/src/events.rs diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts new file mode 100644 index 0000000..79b17b3 --- /dev/null +++ b/bindings/CreateEventRequest.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 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, }; diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts new file mode 100644 index 0000000..cae6e87 --- /dev/null +++ b/bindings/EventMeta.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 { EventId } from "./EventId"; + +/** + * 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, }; diff --git a/crates/app/src/director.rs b/crates/app/src/director.rs index 48a4f0a..dabbec2 100644 --- a/crates/app/src/director.rs +++ b/crates/app/src/director.rs @@ -23,7 +23,8 @@ use std::path::{Path, PathBuf}; use axum::Router; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; -use gridfpv_server::app::{AppState, router, smart_fallback}; +use gridfpv_server::app::{router, smart_fallback}; +use gridfpv_server::events::EventRegistry; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; @@ -39,9 +40,11 @@ pub const DEFAULT_ADDR: &str = "0.0.0.0:8080"; pub struct Config { /// The socket address to bind (`GRIDFPV_ADDR`, default [`DEFAULT_ADDR`]). pub addr: SocketAddr, - /// Where the durable event log lives (`GRIDFPV_DB`): `None` ⇒ an in-memory SQLite log - /// (nothing persisted; fresh every start), `Some(path)` ⇒ open/create a file log there. - pub db: Option, + /// 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`]). @@ -52,7 +55,11 @@ impl Config { /// Read the Director config from the environment, applying defaults. /// /// - `GRIDFPV_ADDR` — listen address (default `0.0.0.0:8080`). - /// - `GRIDFPV_DB` — SQLite log path; unset ⇒ in-memory (non-durable). + /// - `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 { @@ -65,9 +72,16 @@ impl Config { .expect("DEFAULT_ADDR is a valid address"), }; - let db = match std::env::var("GRIDFPV_DB") { + let data_dir = match std::env::var("GRIDFPV_DATA_DIR") { Ok(value) if !value.trim().is_empty() => Some(PathBuf::from(value)), - _ => None, + _ => 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") { @@ -75,7 +89,11 @@ impl Config { _ => default_assets_dir(), }; - Ok(Self { addr, db, assets }) + Ok(Self { + addr, + data_dir, + assets, + }) } } @@ -112,7 +130,7 @@ pub fn default_assets_dir() -> PathBuf { /// 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`). -pub fn build_app(state: AppState, assets: &Path) -> Router { +pub fn build_app(registry: EventRegistry, assets: &Path) -> Router { // 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 @@ -122,7 +140,7 @@ pub fn build_app(state: AppState, assets: &Path) -> Router { let index_html = assets.join("index.html"); let serve_dir = ServeDir::new(assets).fallback(spa_fallback(index_html)); - router(state) + 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(serve_dir)) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 27a1d6b..d55c482 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -15,10 +15,10 @@ #![forbid(unsafe_code)] use gridfpv_app::director::{AssetStatus, Config, asset_status, build_app}; -use gridfpv_app::source::{SIM_ADAPTER, SourceConfig, spawn_bridge}; +use gridfpv_app::source::{SIM_ADAPTER, SourceConfig, spawn_registry_bridge}; use gridfpv_app::{SyntheticPilot, append_and_project, render_lap_list, synthetic_session}; use gridfpv_events::AdapterId; -use gridfpv_server::app::AppState; +use gridfpv_server::events::EventRegistry; use gridfpv_storage::SqliteLog; fn main() -> Result<(), Box> { @@ -38,37 +38,33 @@ fn main() -> Result<(), Box> { async fn serve() -> Result<(), Box> { let config = Config::from_env()?; - // Open the event log: a file-backed SQLite log when `GRIDFPV_DB` is a path, else an - // in-memory one (non-durable, fresh each start). Either way it is the one append-only - // log every protocol path shares. - let state = match &config.db { - Some(path) => { - let log = SqliteLog::open(path)?; - AppState::new(log) - } - None => AppState::new(SqliteLog::open_in_memory()?), - }; + // Build the event registry — events are first-class containers (#72), each with its own + // log. The built-in **Practice** event (in-memory, non-persistent) is always present; + // events created via `POST /events` get a SQLite file under the configured data dir. + let registry = EventRegistry::new(config.data_dir.clone())?; // Mint (or pin) the RD's control token up front and print it with the URL so the RD - // console (or the Tauri app) can log straight in. The store is in-memory, so this is the - // token for *this* run of the process. If `GRIDFPV_RD_TOKEN` is set to a non-blank value - // it is registered as the RD token verbatim — a *known* credential so an automated client - // (the Playwright e2e, the Tauri app under test) can log in deterministically; otherwise a - // fresh random token is minted. + // console (or the Tauri app) can log straight in. The store is Director-wide (shared + // across every event), so one token controls all events. If `GRIDFPV_RD_TOKEN` is set to + // a non-blank value it is registered verbatim — a *known* credential so an automated + // client (the Playwright e2e, the Tauri app under test) can log in deterministically; + // otherwise a fresh random token is minted. + let tokens = registry.tokens(); let rd_token = match std::env::var("GRIDFPV_RD_TOKEN") { - Ok(value) if state.tokens().register_rd_token(&value) => value, - _ => state.tokens().issue_rd_token(), + Ok(value) if tokens.register_rd_token(&value) => value, + _ => tokens.issue_rd_token(), }; - // Resolve the built-in lap source (default `sim`) and spawn the control→source bridge - // alongside the server: it shares this same `AppState`/log, watches for heats driven to - // `Running` through the control path, and appends synthetic laps for them in real time - // (see [`gridfpv_app::source`]). It runs until the process exits. + // Resolve the built-in lap source (default `sim`) and spawn the **per-event** + // control→source bridge over the registry: each event (Practice + any created event) gets + // its own bridge feeding sim passes into ITS own log when a heat goes `Running` there. It + // runs until the process exits (see [`gridfpv_app::source::spawn_registry_bridge`]). let source = SourceConfig::from_env(); let source_desc = source.describe(); - let _bridge = spawn_bridge(state.clone(), source, AdapterId(SIM_ADAPTER.to_string())); + let _bridge = + spawn_registry_bridge(registry.clone(), source, AdapterId(SIM_ADAPTER.to_string())); - let app = build_app(state, &config.assets); + let app = build_app(registry, &config.assets); let listener = tokio::net::TcpListener::bind(config.addr).await?; let bound = listener.local_addr()?; @@ -98,11 +94,15 @@ fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &str, s println!(" console URL : http://{url_host}/"); println!(" RD token : {rd_token}"); println!(" (use as `Authorization: Bearer {rd_token}` on the control path)"); - match &config.db { - Some(path) => println!(" event log : sqlite file {}", path.display()), - None => { - println!(" event log : in-memory sqlite (non-durable — set GRIDFPV_DB to persist)") - } + 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!( diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index b3d43e0..d22a3ab 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -44,6 +44,7 @@ //! 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; use std::time::Duration; @@ -51,6 +52,8 @@ use gridfpv_events::{ AdapterId, CompetitorRef, Event, GateIndex, HeatId, HeatTransition, Pass, SourceTime, }; use gridfpv_server::app::AppState; +use gridfpv_server::events::EventRegistry; +use gridfpv_server::scope::EventId; use gridfpv_storage::Offset; use tokio::task::JoinHandle; @@ -306,6 +309,51 @@ pub fn spawn_bridge(state: AppState, source: SourceConfig, adapter: AdapterId) - }) } +/// 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). +/// +/// With events now first-class containers, each event has its **own** log, so the sim source +/// is per-event: the Director runs one [`run_bridge`] per event, feeding sim passes into THAT +/// event's log when a heat goes `Running` *there*. This spawner seeds a bridge for every event +/// present at startup (the built-in 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 Practice and any created event can run a sim race +/// independently and concurrently. +/// +/// 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<()> { + let source = source.into_source(); + 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 { + for meta in registry.list() { + if attached.contains(&meta.id) { + continue; + } + // Resolve the event's own AppState/log and spawn its bridge. + if let Some(state) = registry.resolve(&meta.id) { + let source = Arc::clone(&source); + let adapter = adapter.clone(); + tokio::spawn(async move { + run_bridge(state, source, adapter).await; + }); + attached.insert(meta.id); + } + } + ticker.tick().await; + } + }) +} + /// The bridge loop: poll the log tail, drive the source on `Running`, cancel on /// `Finished`/`Aborted`/`Scored`/`Restarted` (or a newer heat going `Running`). /// diff --git a/crates/app/tests/director.rs b/crates/app/tests/director.rs index eab34ac..694b4b1 100644 --- a/crates/app/tests/director.rs +++ b/crates/app/tests/director.rs @@ -12,8 +12,7 @@ use std::path::{Path, PathBuf}; use axum::body::Body; use axum::http::{Request, StatusCode}; use gridfpv_app::director::{AssetStatus, asset_status, build_app, default_assets_dir}; -use gridfpv_server::app::AppState; -use gridfpv_storage::InMemoryLog; +use gridfpv_server::events::EventRegistry; use http_body_util::BodyExt; use tower::ServiceExt; @@ -30,8 +29,8 @@ fn temp_dir(tag: &str) -> PathBuf { /// `GET ` against the Director router over an empty in-memory log. async fn get(assets: &Path, uri: &str) -> (StatusCode, String) { - let state = AppState::new(InMemoryLog::new()); - let app = build_app(state, assets); + 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 @@ -54,7 +53,7 @@ async fn health_endpoint_is_served() { 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, "/snapshot/event/spring-cup").await; + let (status, _body) = get(&assets, "/events/practice/snapshot/event/spring-cup").await; assert_eq!(status, StatusCode::OK); } @@ -106,13 +105,13 @@ async fn cors_preflight_is_permissive() { let dir = temp_dir("cors"); std::fs::write(dir.join("index.html"), "shell").unwrap(); - let state = AppState::new(InMemoryLog::new()); - let app = build_app(state, &dir); + let registry = EventRegistry::new(None).unwrap(); + let app = build_app(registry, &dir); let response = app .oneshot( Request::builder() .method("OPTIONS") - .uri("/snapshot/event/spring-cup") + .uri("/events/practice/snapshot/event/spring-cup") .header("Origin", "http://tauri.localhost") .header("Access-Control-Request-Method", "GET") .body(Body::empty()) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index de1c0bb..5ce879f 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -89,6 +89,7 @@ use tokio::sync::Notify; use crate::auth::{JoinTokenResponse, TokenStore}; use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; +use crate::events::{CreateEventRequest, EventMeta, EventRegistry}; use crate::live_state::live_state; use crate::scope::{ClassId, EventId, PilotId}; use crate::snapshot::{ProjectionBody, Snapshot}; @@ -194,6 +195,19 @@ impl AppState { } } + /// 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, + } + } + /// 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. @@ -255,46 +269,106 @@ impl AppState { } } -/// Build the snapshot [`Router`] (protocol.html §2, §4) over the shared [`AppState`]. +/// Build the **event-rooted** protocol [`Router`] over the [`EventRegistry`] (issue #72). /// -/// The four scope addressing schemes (see the module docs) plus a liveness `GET /health`. -/// The WS stream (#43), auth middleware (#44), and control routes (#45) layer onto this -/// same router and state. -pub fn router(state: AppState) -> Router { +/// 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" })) - .route("/snapshot/event/{event}", get(snapshot_event)) - .route("/snapshot/class/{event}/{class}", get(snapshot_class)) - .route("/snapshot/heat/{heat}", get(snapshot_heat)) - .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot)) - .route("/stream", get(crate::ws::stream_handler)) - // RD-gated mint of a read-only join token (#63): an authenticated RD trades its RD - // token for a fresh spectator (read-only) token to share (e.g. a venue QR). - .route("/auth/join-token", post(mint_join_token)); - // The privileged RD control surface (§5) is composed on separately so #44 can wrap - // just these routes in its auth layer. + // Events lifecycle (issue #72): list (Practice first) and RD-gated create. + .route("/events", get(list_events).post(create_event)) + // 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(state) + .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), + ) + }) } -/// `POST /auth/join-token` — mint a fresh **read-only** join token (protocol.html §5, -/// §9.4) — issue #63. +/// `GET /events` — list every event's [`EventMeta`], Practice first (issue #72). /// -/// [`ControlAuth`] runs first: only an authenticated **RD** (a valid `Authorization: -/// Bearer `) may mint one; any other caller — no token, a read-only/join token, -/// an unknown or revoked token — is [`ErrorCode::Unauthorized`] → HTTP 401 (the extractor -/// rejects before the handler body runs). On success a brand-new read-only token is issued -/// from the shared [`TokenStore`] and returned as a [`JoinTokenResponse`]; the spectator -/// who later presents it authenticates LAN reads but is rejected on control. +/// 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 display `name`, RD-gated (issue #72). +/// +/// [`ControlAuth`] runs first: only an authenticated **RD** may create an event. The id is +/// **auto-generated** (a slug of the name + a short random suffix) — names are display-only, +/// ids are never user-entered. 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.name) + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?; + Ok(Json(meta)) +} + +/// `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(state): State, -) -> Json { + State(registry): State, + Path(event_id): Path, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; let token = state.tokens().issue_join_token(); - Json(JoinTokenResponse { token }) + Ok(Json(JoinTokenResponse { token })) } /// Whether a request path addresses a known protocol **API tree** (#64). @@ -309,7 +383,18 @@ async fn mint_join_token( /// 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 { - const API_PREFIXES: [&str; 5] = ["/health", "/snapshot", "/stream", "/control", "/auth"]; + // 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; 6] = [ + "/health", + "/events", + "/snapshot", + "/stream", + "/control", + "/auth", + ]; API_PREFIXES.iter().any(|prefix| { path == *prefix || path @@ -407,11 +492,18 @@ struct HeatQuery { projection: HeatProjection, } -/// `GET /snapshot/event/{event}` — the whole event's live race-state (§4 event scope). +/// `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(state): State, - Path(_event): Path, + State(registry): State, + Path((event_id, _event)): Path<(EventId, EventId)>, ) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; Ok(Json(Snapshot { cursor, @@ -425,9 +517,10 @@ async fn snapshot_event( /// the module docs); this serves the whole-event live state under the class address so the /// scope is reachable now and tightens later without an addressing change. async fn snapshot_class( - State(state): State, - Path((_event, _class)): Path<(EventId, ClassId)>, + State(registry): State, + Path((event_id, _event, _class)): Path<(EventId, EventId, ClassId)>, ) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; Ok(Json(Snapshot { cursor, @@ -441,10 +534,11 @@ async fn snapshot_class( /// its [`LapList`]; `?projection=result` its scored [`HeatResult`]. The log is filtered to /// the heat's window so the body is heat-local. async fn snapshot_heat( - State(state): State, - Path(heat): Path, + State(registry): State, + Path((event_id, heat)): Path<(EventId, HeatId)>, Query(query): Query, ) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; // The heat must exist in the log (a `HeatScheduled` for this id), else UnknownScope. @@ -500,9 +594,10 @@ async fn snapshot_heat( /// 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(state): State, - Path((_event, pilot)): Path<(EventId, PilotId)>, + 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 @@ -607,7 +702,6 @@ mod tests { use super::*; use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass}; use gridfpv_projection::CompetitorKey; - use gridfpv_storage::InMemoryLog; use http_body_util::BodyExt; use tower::ServiceExt; @@ -659,17 +753,28 @@ mod tests { ] } - fn state_with(events: Vec) -> (AppState, u64) { - let mut log = InMemoryLog::default(); + 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 { - EventLog::append(&mut log, e.clone(), None).unwrap(); + state.append(e.clone(), None).unwrap(); } let len = events.len() as u64; - (AppState::new(log), len) + (registry, state, len) } - async fn get_snapshot(state: AppState, uri: &str) -> (StatusCode, Option) { - let response = router(state) + 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(); @@ -681,8 +786,9 @@ mod tests { #[tokio::test] async fn event_scope_returns_live_state_and_cursor() { - let (state, len) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await; + 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. @@ -704,8 +810,8 @@ mod tests { #[tokio::test] async fn heat_scope_default_is_live_state() { - let (state, len) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1").await; + 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)); @@ -714,8 +820,12 @@ mod tests { #[tokio::test] async fn heat_scope_laps_projection_returns_lap_list() { - let (state, _) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=laps").await; + 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) => { @@ -733,8 +843,12 @@ mod tests { #[tokio::test] async fn heat_scope_result_projection_returns_heat_result() { - let (state, _) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=result").await; + 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) => { @@ -747,11 +861,11 @@ mod tests { #[tokio::test] async fn unknown_heat_is_not_found() { - let (state, _) = state_with(recorded_heat()); - let response = router(state) + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) .oneshot( Request::builder() - .uri("/snapshot/heat/does-not-exist") + .uri("/events/practice/snapshot/heat/does-not-exist") .body(Body::empty()) .unwrap(), ) @@ -765,8 +879,9 @@ mod tests { #[tokio::test] async fn pilot_scope_filters_to_the_pilot_laps() { - let (state, len) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/pilot/spring-cup/A").await; + 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)); @@ -794,8 +909,12 @@ mod tests { competitor: CompetitorRef("A".into()), pilot: gridfpv_events::PilotId("acroace".into()), }); - let (state, _) = state_with(events); - let (status, snap) = get_snapshot(state, "/snapshot/pilot/spring-cup/acroace").await; + 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) => { @@ -812,11 +931,11 @@ mod tests { #[tokio::test] async fn unknown_pilot_is_not_found() { - let (state, _) = state_with(recorded_heat()); - let response = router(state) + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) .oneshot( Request::builder() - .uri("/snapshot/pilot/spring-cup/nobody") + .uri("/events/practice/snapshot/pilot/spring-cup/nobody") .body(Body::empty()) .unwrap(), ) @@ -827,8 +946,9 @@ mod tests { #[tokio::test] async fn class_scope_is_reachable() { - let (state, len) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/class/spring-cup/open").await; + 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)); @@ -837,8 +957,9 @@ mod tests { #[tokio::test] async fn empty_log_event_scope_is_idle_with_zero_cursor() { - let (state, _) = state_with(vec![]); - let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await; + 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)); @@ -874,9 +995,13 @@ mod tests { pass("B", 13_000_000, 2), pass("B", 15_000_000, 3), // q-2: B two laps ]; - let (state, _) = state_with(events); + let (registry, _state, _) = state_with(events); - let (_, snap) = get_snapshot(state.clone(), "/snapshot/heat/q-1?projection=laps").await; + 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. @@ -889,7 +1014,11 @@ mod tests { other => panic!("expected lap list, got {other:?}"), } - let (_, snap) = get_snapshot(state, "/snapshot/heat/q-2?projection=laps").await; + 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); @@ -907,8 +1036,8 @@ mod tests { /// 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(state: AppState, uri: &str) -> (StatusCode, Option) { - let response = router(state) + 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(); @@ -948,8 +1077,8 @@ mod tests { #[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 (state, _) = state_with(recorded_heat()); - let (status, err) = get_raw(state, "/snapshot/zzz/nope/extra").await; + 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, @@ -959,8 +1088,8 @@ mod tests { #[tokio::test] async fn bogus_control_path_is_typed_404() { - let (state, _) = state_with(recorded_heat()); - let (status, err) = get_raw(state, "/control/bogus").await; + 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, @@ -970,8 +1099,8 @@ mod tests { #[tokio::test] async fn a_real_route_still_works_alongside_the_api_fallback() { - let (state, _) = state_with(recorded_heat()); - let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1").await; + 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, @@ -1024,14 +1153,16 @@ mod tests { /// `POST /auth/join-token` with an optional bearer token; returns status + parsed body. async fn post_join_token( - state: AppState, + registry: EventRegistry, token: Option<&str>, ) -> (StatusCode, Option) { - let mut builder = Request::builder().method("POST").uri("/auth/join-token"); + 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(state) + let response = router(registry) .oneshot(builder.body(Body::empty()).unwrap()) .await .unwrap(); @@ -1045,11 +1176,11 @@ mod tests { async fn rd_token_mints_a_join_token_that_reads_but_cannot_control() { use crate::auth::Role; - let (state, _) = state_with(recorded_heat()); + 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(state.clone(), Some(&rd)).await; + 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()); @@ -1074,19 +1205,19 @@ mod tests { #[tokio::test] async fn minting_a_join_token_requires_an_rd_token() { - let (state, _) = state_with(recorded_heat()); + let (registry, state, _) = state_with(recorded_heat()); // No token → 401. - let (status, _) = post_join_token(state.clone(), None).await; + 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(state.clone(), Some(&join)).await; + let (status, _) = post_join_token(registry.clone(), Some(&join)).await; assert_eq!(status, StatusCode::UNAUTHORIZED); // An unknown token → 401. - let (status, _) = post_join_token(state, Some("not-a-real-token")).await; + let (status, _) = post_join_token(registry, Some("not-a-real-token")).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } } diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index b1ca148..368148a 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -79,7 +79,7 @@ use axum::Json; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{FromRequestParts, State}; +use axum::extract::{FromRequestParts, Path, State}; use axum::http::request::Parts; use axum::response::Response; use axum::routing::get; @@ -87,9 +87,11 @@ use axum::{Router, routing::MethodRouter}; use gridfpv_engine::heat::{self, HeatCommand}; use gridfpv_events::{Event, HeatId, LogRef}; -use crate::app::AppState; +use crate::app::{AppState, resolve_event}; use crate::control::{Command, CommandAck}; use crate::error::{ErrorCode, ProtocolError}; +use crate::events::EventRegistry; +use crate::scope::EventId; /// The **auth chokepoint** for the privileged control path (protocol.html §5, §9.4) — #44. /// @@ -113,17 +115,20 @@ pub struct ControlAuth { _private: (), } -impl FromRequestParts for ControlAuth { +impl FromRequestParts for ControlAuth { type Rejection = ProtocolError; async fn from_request_parts( parts: &mut Parts, - state: &AppState, + 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. + // 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); - state.tokens().authenticate_control(token.as_deref())?; + registry.tokens().authenticate_control(token.as_deref())?; Ok(ControlAuth { _private: () }) } } @@ -133,12 +138,12 @@ impl FromRequestParts for ControlAuth { /// Adds `GET /control` (the bidirectional control WebSocket) and `POST /control` (the /// one-shot request/reply). Kept separate from [`crate::app::router`] so the control /// surface is composed explicitly and #44 can wrap *just* these routes in its auth layer. -pub fn control_routes(router: Router) -> Router { - router.route("/control", control_method_router()) +pub fn control_routes(router: Router) -> Router { + router.route("/events/{event_id}/control", control_method_router()) } -/// `GET /control` (WS upgrade) + `POST /control` (one-shot) on the one path. -fn control_method_router() -> MethodRouter { +/// `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) } @@ -153,10 +158,14 @@ fn control_method_router() -> MethodRouter { /// shared [`ProtocolError`]). async fn control_post( _auth: ControlAuth, - State(state): State, + State(registry): State, + Path(event_id): Path, Json(command): Json, -) -> Json { - Json(apply_command(&state, command)) +) -> 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(&state, command))) } /// `GET /control` — upgrade to the bidirectional control WebSocket (protocol.html §5). @@ -166,9 +175,13 @@ async fn control_post( async fn control_ws( _auth: ControlAuth, ws: WebSocketUpgrade, - State(state): State, -) -> Response { - ws.on_upgrade(move |socket| run_control(socket, state)) + 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, state))) } /// Drive one control socket: read [`Command`] frames, write a [`CommandAck`] per command diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs new file mode 100644 index 0000000..786cd6f --- /dev/null +++ b/crates/server/src/events.rs @@ -0,0 +1,421 @@ +//! 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_storage::{InMemoryLog, SqliteLog}; + +use crate::app::AppState; +use crate::auth::TokenStore; +use crate::scope::EventId; + +/// 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 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, +} + +/// 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, +} + +/// 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, + /// 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, +} + +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(); + + // 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, + }, + state: practice_state, + }, + ); + + if let Some(dir) = &data_dir { + std::fs::create_dir_all(dir).map_err(|e| { + RegistryError(format!("could not create data dir {}: {e}", dir.display())) + })?; + } + + Ok(Self { + inner: Arc::new(RwLock::new(Registry { + events, + tokens, + data_dir, + })), + }) + } + + /// 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 display `name`, returning its [`EventMeta`]. + /// + /// The **id is auto-generated** — a slug of `name` plus a short random suffix — so it is + /// unique and never user-entered (names are display-only). 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, name: &str) -> Result { + 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(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, + ), + }; + + let meta = EventMeta { + id: id.clone(), + name: name.to_string(), + created_at: now_millis(), + persistent, + }; + reg.events.insert( + id, + RegisteredEvent { + meta: meta.clone(), + state, + }, + ); + Ok(meta) + } + + 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!("{}.sqlite", id.0)) +} + +/// An error creating an event or its registry (a storage failure, a bad data dir). +#[derive(Debug, Clone)] +pub struct RegistryError(pub String); + +impl std::fmt::Display for RegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "event registry error: {}", self.0) + } +} + +impl std::error::Error for RegistryError {} + +/// 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) +} + +/// 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}; + + #[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("Spring Cup 2026!").unwrap(); + let b = reg.create("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("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())], + }, + 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("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 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("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(); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 9b3795e..14d05f3 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -61,6 +61,7 @@ pub mod auth; pub mod control; pub mod control_handler; pub mod error; +pub mod events; pub mod live_state; pub mod scope; pub mod snapshot; diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs index a7f5733..1152e39 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -74,16 +74,17 @@ //! that preference per envelope so #59 can swap the encoding in without reshaping the //! stream or its sequencing. -use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::response::Response; +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Response}; use gridfpv_events::{CompetitorRef, Event}; use gridfpv_projection::{LapList, lap_list_marshaled}; -use crate::app::{AppState, heat_window}; +use crate::app::{AppState, heat_window, resolve_event}; use crate::error::{ErrorCode, ProtocolError}; +use crate::events::EventRegistry; use crate::live_state::live_state; -use crate::scope::Scope; +use crate::scope::{EventId, Scope}; use crate::snapshot::{ProjectionBody, ProjectionKind}; use crate::stream::{Change, ChangeEnvelope, Cursor, StreamMessage}; @@ -198,11 +199,23 @@ impl ScopeProjection { } } -/// `GET /stream` — upgrade to the change-stream WebSocket (protocol.html §3, §9.1). +/// `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 upgrades the connection and hands it to [`run_stream`]; auth (#44) and -/// control command handling (#45) layer on separately — this is the read stream only. -pub async fn stream_handler(ws: WebSocketUpgrade, State(state): State) -> Response { +/// 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)) } diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index e309dd1..48b2945 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -16,13 +16,15 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use gridfpv_events::{CompetitorRef, HeatId}; -use gridfpv_server::app::{AppState, router}; +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 gridfpv_storage::InMemoryLog; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::http::StatusCode; use tokio_tungstenite::tungstenite::http::Uri; @@ -34,17 +36,28 @@ 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(state: AppState) -> (String, String, tokio::task::JoinHandle<()>) { - let rd_token = state.tokens().issue_rd_token(); +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(state); + 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()) } @@ -65,7 +78,7 @@ async fn post_raw(addr: &str, command: &Command, token: Option<&str>) -> (u16, O .map(|t| format!("Authorization: Bearer {t}\r\n")) .unwrap_or_default(); let request = format!( - "POST /control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + "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() ); @@ -96,7 +109,9 @@ async fn post_command(addr: &str, command: &Command, token: &str) -> 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}/control").parse().unwrap(); + 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(); @@ -121,7 +136,9 @@ async fn send_command(ws: &mut Ws, command: &Command) -> CommandAck { /// Subscribe a `/stream` reader and await the next live-state phase. async fn subscribe_stream(addr: &str, scope: Scope) -> Ws { - let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap(); + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); let request = SubscribeRequest { scope, from: None, @@ -159,8 +176,8 @@ async fn next_phase(ws: &mut Ws) -> HeatPhase { /// reaches a `/stream` subscriber (the read-back, §5). #[tokio::test] async fn post_command_drives_heat_loop_and_reaches_stream() { - let state = AppState::new(InMemoryLog::default()); - let (addr, rd, _server) = serve(state.clone()).await; + 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( @@ -195,8 +212,8 @@ async fn post_command_drives_heat_loop_and_reaches_stream() { /// readable on `/stream`. #[tokio::test] async fn control_ws_acks_each_command_and_rejects_illegal() { - let state = AppState::new(InMemoryLog::default()); - let (addr, rd, _server) = serve(state.clone()).await; + let (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; let mut control = control_ws(&addr, &rd).await; @@ -243,8 +260,8 @@ async fn control_ws_acks_each_command_and_rejects_illegal() { /// well-formed command still works on the same session. #[tokio::test] async fn control_ws_survives_a_malformed_frame() { - let state = AppState::new(InMemoryLog::default()); - let (addr, rd, _server) = serve(state.clone()).await; + let (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; let mut control = control_ws(&addr, &rd).await; control @@ -279,9 +296,9 @@ async fn control_ws_survives_a_malformed_frame() { /// 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 state = AppState::new(InMemoryLog::default()); + let (registry, state) = practice_registry(); let join_token = state.tokens().issue_join_token(); - let (addr, rd, _server) = serve(state.clone()).await; + let (addr, rd, _server) = serve(registry.clone()).await; let cmd = Command::ScheduleHeat { heat: heat(), lineup: vec![], @@ -307,19 +324,21 @@ async fn control_post_requires_a_valid_rd_token() { /// [`StatusCode::UNAUTHORIZED`] is surfaced by the handshake error. #[tokio::test] async fn control_ws_upgrade_requires_a_valid_rd_token() { - let state = AppState::new(InMemoryLog::default()); + let (registry, state) = practice_registry(); let join_token = state.tokens().issue_join_token(); - let (addr, rd, _server) = serve(state.clone()).await; + let (addr, rd, _server) = serve(registry.clone()).await; // No token: the upgrade is refused. - let no_token = connect_async(format!("ws://{addr}/control")).await; + 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}/control").parse().unwrap(); + 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 { @@ -346,9 +365,9 @@ async fn control_ws_upgrade_requires_a_valid_rd_token() { /// 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 state = AppState::new(InMemoryLog::default()); + let (registry, state) = practice_registry(); let join_token = state.tokens().issue_join_token(); - let (addr, rd, _server) = serve(state.clone()).await; + 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( @@ -367,7 +386,9 @@ async fn reads_are_open_and_a_join_token_authenticates_reads() { 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}/stream")).await.unwrap(); + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); let request = SubscribeRequest { scope: Scope::Heat { heat: heat() }, from: None, @@ -388,8 +409,8 @@ async fn out_of_band_contract_version_is_told_to_refresh() { use gridfpv_server::ContractVersion; use gridfpv_server::error::{ErrorCode, ProtocolError}; - let state = AppState::new(InMemoryLog::default()); - let (addr, rd, _server) = serve(state.clone()).await; + let (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; let ack = post_command( &addr, &Command::ScheduleHeat { @@ -402,7 +423,9 @@ async fn out_of_band_contract_version_is_told_to_refresh() { assert!(ack.ok); // Too-new version → the stream closes with a VersionMismatch refresh signal. - let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap(); + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); let too_new = SubscribeRequest { scope: Scope::Heat { heat: heat() }, from: None, @@ -429,7 +452,9 @@ async fn out_of_band_contract_version_is_told_to_refresh() { } // An in-band version connects and streams normally. - let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap(); + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); let in_band = SubscribeRequest { scope: Scope::Heat { heat: heat() }, from: None, diff --git a/crates/server/tests/ws_stream.rs b/crates/server/tests/ws_stream.rs index fd488fa..68e8030 100644 --- a/crates/server/tests/ws_stream.rs +++ b/crates/server/tests/ws_stream.rs @@ -19,10 +19,10 @@ 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 gridfpv_storage::InMemoryLog; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; @@ -31,14 +31,24 @@ type Ws = WebSocketStream>; /// Serve `router(state)` on an ephemeral port; return the `ws://…/stream` URL and the /// server task's join handle (dropped at test end, which aborts the task). -async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { +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(state); + let app = router(registry); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (format!("ws://{addr}/stream"), handle) + (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. @@ -100,8 +110,8 @@ fn event_scope() -> Scope { /// gap-free envelopes whose final state matches the server's fold. #[tokio::test] async fn streams_ordered_envelopes_from_a_fresh_subscribe() { - let state = AppState::new(InMemoryLog::default()); - let (url, _server) = serve(state.clone()).await; + let (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; let mut ws = subscribe( &url, @@ -151,8 +161,8 @@ async fn streams_ordered_envelopes_from_a_fresh_subscribe() { /// at 1. #[tokio::test] async fn resume_from_a_mid_cursor_replays_only_the_tail() { - let state = AppState::new(InMemoryLog::default()); - let (url, _server) = serve(state.clone()).await; + let (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; // Pre-load three events (offsets 0,1,2 → log length 3). state @@ -196,8 +206,8 @@ async fn resume_from_a_mid_cursor_replays_only_the_tail() { /// signal instead of a replay (protocol.html §3, §9.3). #[tokio::test] async fn too_old_cursor_requires_re_snapshot() { - let state = AppState::new(InMemoryLog::default()); - let (url, _server) = serve(state.clone()).await; + 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; @@ -229,8 +239,8 @@ async fn too_old_cursor_requires_re_snapshot() { /// one envelope per *change*, keeping the per-stream sequence a faithful change count). #[tokio::test] async fn unchanged_fold_emits_no_envelope() { - let state = AppState::new(InMemoryLog::default()); - let (url, _server) = serve(state.clone()).await; + let (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; let mut ws = subscribe( &url, @@ -267,8 +277,8 @@ async fn unchanged_fold_emits_no_envelope() { /// A malformed first frame closes the socket with a BadRequest close (no panic, no hang). #[tokio::test] async fn malformed_subscribe_closes_the_socket() { - let state = AppState::new(InMemoryLog::default()); - let (url, _server) = serve(state).await; + let (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")) From ce7b00a2f30857b60ed5cabac94a2cc8c2dd1673 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 20:10:16 +0000 Subject: [PATCH 047/362] Plumb the event-rooted protocol through the frontend + contract suite (#72) Keep the whole stack green for the event-container change. - protocol-client: connect() takes an eventId (default `practice`) and builds /events/{id}/{snapshot,stream} URLs; add listEvents() + createEvent(name). - rd-console: control client POSTs /events/{eventId}/control; the Session connects to the Practice event by default for reads, control, and the heat-result fetch (the startup/event-picker UI is a separate follow-up PR). - contract suite: route every per-event seam under /events/practice; add a new events.contract.ts covering GET /events (Practice first), POST /events (RD-gated, auto-id, persistent under a data dir), per-event isolation, and the unknown-event 404. All contract tests green. - @gridfpv/types re-exports EventMeta + CreateEventRequest; unit tests updated. The browser e2e runs its heat inside the default Practice event and still passes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/lib/control.ts | 14 +- .../apps/rd-console/src/lib/session.svelte.ts | 38 ++++- .../apps/rd-console/tests/control.test.ts | 5 +- .../rd-console/tests/session.svelte.test.ts | 3 +- frontend/contract/control.contract.ts | 27 ++-- frontend/contract/events.contract.ts | 141 ++++++++++++++++++ frontend/contract/harness.ts | 15 +- frontend/contract/race.contract.ts | 4 +- frontend/contract/snapshot.contract.ts | 24 +-- frontend/contract/stream.contract.ts | 31 +++- .../protocol-client/src/client.test.ts | 88 +++++++++++ .../packages/protocol-client/src/client.ts | 90 +++++++++-- .../packages/protocol-client/src/index.ts | 2 +- frontend/packages/types/src/generated.ts | 2 + 14 files changed, 428 insertions(+), 56 deletions(-) create mode 100644 frontend/contract/events.contract.ts diff --git a/frontend/apps/rd-console/src/lib/control.ts b/frontend/apps/rd-console/src/lib/control.ts index 325d284..17e8817 100644 --- a/frontend/apps/rd-console/src/lib/control.ts +++ b/frontend/apps/rd-console/src/lib/control.ts @@ -75,10 +75,18 @@ function failedAck(code: ProtocolError['code'], message: string): CommandAck { return { ok: false, error: { code, message } }; } +/** The built-in Practice event id — the default the control client targets (issue #72). */ +export const PRACTICE_EVENT_ID = 'practice'; + /** Options for {@link createControlClient}. */ export interface ControlClientOptions { /** Inject a `fetch` (defaults to the global). Used by tests and Node. */ fetch?: FetchLike; + /** + * The **event** to drive control on (issue #72). Control is now rooted under the event: + * `POST /events/{eventId}/control`. Defaults to the built-in `practice` event. + */ + eventId?: string; } /** @@ -86,6 +94,7 @@ export interface ControlClientOptions { * * @param baseUrl Director protocol server base URL (same one the read client uses). * @param token Optional bearer token, sent as `Authorization: Bearer `. + * @param options Optional `fetch` injection and the `eventId` to drive (default Practice). */ export function createControlClient( baseUrl: string, @@ -94,6 +103,9 @@ export function createControlClient( ): ControlClient { const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); const base = trimSlash(baseUrl); + // Commands are rooted under the event (#72): `/events/{eventId}/control`. + const eventId = options.eventId ?? PRACTICE_EVENT_ID; + const controlPath = `/events/${encodeURIComponent(eventId)}/control`; return { baseUrl: base, @@ -106,7 +118,7 @@ export function createControlClient( let resp: Response; try { - resp = await fetchImpl(`${base}/control`, { + resp = await fetchImpl(`${base}${controlPath}`, { method: 'POST', headers, body: stringifyCommand(command) diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 6325e50..a01b945 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -31,6 +31,14 @@ import type { const STORAGE_KEY = 'gridfpv.rd.session'; +/** + * The built-in **Practice** event id the console connects to by default (issue #72). Events + * are now first-class containers — every read/realtime/control surface is rooted under one. + * The full startup/event-picker UI is a follow-up PR; until then the console defaults to the + * always-present Practice event so the whole stack works end to end. + */ +const PRACTICE_EVENT_ID = 'practice'; + interface StoredSession { baseUrl: string; token: string; @@ -93,6 +101,8 @@ export class Session { // Non-reactive internals. #token: string | undefined; + /** The event every surface is rooted under (#72); defaults to the built-in Practice event. */ + #eventId: string = PRACTICE_EVENT_ID; #client: ProtocolClient | undefined; #control: ControlClient | undefined; #unsub: (() => void) | undefined; @@ -122,14 +132,21 @@ export class Session { this.#token = token; this.baseUrl = baseUrl; this.authenticated = true; - this.#control = this.#controlFactory(baseUrl, token); + // Control is rooted under the current event (#72) — default Practice. + this.#control = this.#controlFactory(baseUrl, token, { eventId: this.#eventId }); persist({ baseUrl, token }); - // Default to the whole event; a real event id is filled by the setup wizard, but - // the console connects optimistically so connection status is visible at login. - const liveScope: Scope = scope ?? { Event: { event: 'event' } }; + // Default to the whole (Practice) event so the console has a live view from login. The + // event id is the built-in Practice event (#72); the startup/event-picker UI that lets + // the RD choose another event is a follow-up PR. + const liveScope: Scope = scope ?? { Event: { event: this.#eventId } }; this.connectionStatus = 'connecting'; - this.#client = this.#connectImpl({ baseUrl, scope: liveScope, token }); + this.#client = this.#connectImpl({ + baseUrl, + eventId: this.#eventId, + scope: liveScope, + token + }); this.#unsub = this.#client.onState((state) => { this.protocolState = state; this.connectionStatus = state.status; @@ -143,7 +160,12 @@ export class Session { this.#unsub?.(); this.#client?.close(); this.connectionStatus = 'connecting'; - this.#client = this.#connectImpl({ baseUrl: this.baseUrl, scope, token: this.#token }); + this.#client = this.#connectImpl({ + baseUrl: this.baseUrl, + eventId: this.#eventId, + scope, + token: this.#token + }); this.#unsub = this.#client.onState((state) => { this.protocolState = state; this.connectionStatus = state.status; @@ -203,7 +225,9 @@ export class Session { if (this.#token) headers.Authorization = `Bearer ${this.#token}`; try { const resp = await globalThis.fetch( - `${base}/snapshot/heat/${encodeURIComponent(heat)}?projection=result`, + `${base}/events/${encodeURIComponent(this.#eventId)}/snapshot/heat/${encodeURIComponent( + heat + )}?projection=result`, { headers } ); if (!resp.ok) return undefined; diff --git a/frontend/apps/rd-console/tests/control.test.ts b/frontend/apps/rd-console/tests/control.test.ts index 1c28cb1..fb391e3 100644 --- a/frontend/apps/rd-console/tests/control.test.ts +++ b/frontend/apps/rd-console/tests/control.test.ts @@ -17,8 +17,9 @@ function mockFetch(impl: FetchLike) { } describe('createControlClient', () => { - it('POSTs the JSON-serialized Command to {baseUrl}/control with the bearer token', async () => { + it('POSTs the JSON-serialized Command to {baseUrl}/events/{eventId}/control with the bearer token', async () => { const fetch = mockFetch(async () => jsonResponse(okAck)); + // No explicit eventId → the control path is rooted under the default Practice event (#72). const client = createControlClient('http://d.local:8080/', 'tok-123', { fetch }); const cmd: Command = { Stage: { heat: 'heat-1' } }; @@ -27,7 +28,7 @@ describe('createControlClient', () => { expect(ack).toEqual(okAck); expect(fetch).toHaveBeenCalledOnce(); const [url, init] = fetch.mock.calls[0]; - expect(url).toBe('http://d.local:8080/control'); + expect(url).toBe('http://d.local:8080/events/practice/control'); expect(init?.method).toBe('POST'); const headers = init?.headers as Record; expect(headers.Authorization).toBe('Bearer tok-123'); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 2c77fed..f45869e 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -39,7 +39,8 @@ describe('Session', () => { expect(session.authenticated).toBe(true); expect(connect).toHaveBeenCalledOnce(); - expect(controlFactory).toHaveBeenCalledWith('http://d.local', 'tok'); + // The control client is rooted under the default Practice event (#72). + expect(controlFactory).toHaveBeenCalledWith('http://d.local', 'tok', { eventId: 'practice' }); // Stream pushes a LiveRaceState body → session.liveState reflects it. push({ body: { LiveRaceState: liveRunning }, cursor: 1, status: 'live', error: undefined }); diff --git a/frontend/contract/control.contract.ts b/frontend/contract/control.contract.ts index c478d99..4beea93 100644 --- a/frontend/contract/control.contract.ts +++ b/frontend/contract/control.contract.ts @@ -26,6 +26,7 @@ import type { Command, JoinTokenResponse } from '@gridfpv/types'; import { type Director } from '../test-harness/director.ts'; import { + eventRoot, openSocket, postControl, rdControl, @@ -53,7 +54,7 @@ describe('seam 5: control command shape + headers', () => { ScheduleHeat: { heat: 'h-shape', lineup: ['A', 'B'] } }); expect(ack).toEqual({ ok: true }); - const res = await fetch(`${director.baseUrl}/snapshot/heat/h-shape`); + const res = await fetch(`${eventRoot(director.baseUrl)}/snapshot/heat/h-shape`); expect(res.status).toBe(200); // it now resolves — the append took effect }); @@ -135,10 +136,12 @@ describe('seam 5: control command shape + headers', () => { await rdControl(director.baseUrl, TOKEN, { ScheduleHeat: { heat: 'h-reaches', lineup: ['A'] } }); - const snap = (await (await fetch(`${director.baseUrl}/snapshot/heat/h-reaches`)).json()) as { + const snap = (await ( + await fetch(`${eventRoot(director.baseUrl)}/snapshot/heat/h-reaches`) + ).json()) as { cursor: number; }; - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/stream`); ws.send(JSON.stringify({ scope: { Heat: { heat: 'h-reaches' } }, from: snap.cursor })); // Drive a change over the CONTROL path; it must surface on the READ stream. await rdControl(director.baseUrl, TOKEN, { Stage: { heat: 'h-reaches' } }); @@ -155,7 +158,7 @@ describe('seam 5: control command shape + headers', () => { }); it('the bidirectional control WS (with the auth header) acks commands', async () => { - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/control`, { + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/control`, { Authorization: `Bearer ${TOKEN}` }); ws.send(JSON.stringify({ ScheduleHeat: { heat: 'h-ws', lineup: ['A'] } })); @@ -191,18 +194,18 @@ describe('seam 6: auth gates control, reads stay open', () => { }); it('the control WS upgrade is rejected without the auth header', async () => { - const withAuth = await tryOpenControlWs(`${wsBase(director.baseUrl)}/control`, { + const withAuth = await tryOpenControlWs(`${wsBase(eventRoot(director.baseUrl))}/control`, { Authorization: `Bearer ${TOKEN}` }); - const withoutAuth = await tryOpenControlWs(`${wsBase(director.baseUrl)}/control`); + const withoutAuth = await tryOpenControlWs(`${wsBase(eventRoot(director.baseUrl))}/control`); expect(withAuth).toBe(true); expect(withoutAuth).toBe(false); }); it('reads are OPEN — /snapshot and /stream need no token', async () => { - const snap = await fetch(`${director.baseUrl}/snapshot/event/any`); + const snap = await fetch(`${eventRoot(director.baseUrl)}/snapshot/event/any`); expect(snap.status).toBe(200); // no Authorization header sent - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/stream`); ws.send(JSON.stringify({ scope: { Event: { event: 'any' } }, from: 0 })); // It subscribes without auth and does not get an Unauthorized error frame. await new Promise((r) => setTimeout(r, 300)); @@ -212,10 +215,10 @@ describe('seam 6: auth gates control, reads stay open', () => { it('minting a join token requires the RD token — no/bad token → 401 (#63)', async () => { // No Authorization → 401. - const anon = await fetch(`${director.baseUrl}/auth/join-token`, { method: 'POST' }); + const anon = await fetch(`${eventRoot(director.baseUrl)}/auth/join-token`, { method: 'POST' }); expect(anon.status).toBe(401); // An unknown token → 401. - const bad = await fetch(`${director.baseUrl}/auth/join-token`, { + const bad = await fetch(`${eventRoot(director.baseUrl)}/auth/join-token`, { method: 'POST', headers: { Authorization: 'Bearer not-a-real-token' } }); @@ -224,7 +227,7 @@ describe('seam 6: auth gates control, reads stay open', () => { it('an RD mints a read-only join token that reads but is REJECTED on control (#63)', async () => { // The RD trades its RD token for a fresh read-only join token over the wire. - const res = await fetch(`${director.baseUrl}/auth/join-token`, { + const res = await fetch(`${eventRoot(director.baseUrl)}/auth/join-token`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` } }); @@ -235,7 +238,7 @@ describe('seam 6: auth gates control, reads stay open', () => { expect(join).not.toBe(TOKEN); // a distinct, freshly-minted credential // The minted join token authenticates a READ (reads accept a valid token of either role). - const read = await fetch(`${director.baseUrl}/snapshot/event/any`, { + const read = await fetch(`${eventRoot(director.baseUrl)}/snapshot/event/any`, { headers: { Authorization: `Bearer ${join}` } }); expect(read.status).toBe(200); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts new file mode 100644 index 0000000..6d075f0 --- /dev/null +++ b/frontend/contract/events.contract.ts @@ -0,0 +1,141 @@ +/** + * Seam 9 (issue #72): events are first-class containers — the events lifecycle API and the + * event-rooted surface. + * + * guards: + * - `GET /events` lists the events, with the built-in **Practice** event always present and + * listed first (in-memory, non-persistent). + * - `POST /events` is RD-gated (no/bad token → 401), auto-generates a unique `id` from the + * display `name` (the id is never user-supplied), and returns the new event's `EventMeta`. + * - the new event is immediately reachable: a snapshot under `/events/{id}/snapshot/...` is a + * 200, and a control command under `/events/{id}/control` acks — against THAT event's own + * log, independent of Practice (a heat scheduled in one is not visible in the other). + * - an unknown event id → a typed `ProtocolError` 404 (`UnknownScope`), the same shape an + * unknown heat/pilot gets. + * + * Everything drives the real Director over the real wire — no mocks. + */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Command, EventMeta } from '@gridfpv/types'; + +import { type Director } from '../test-harness/director.ts'; +import { eventRoot, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-events-contract'; + +let director: Director; + +beforeAll(async () => { + // Configure a data dir so created events are genuinely persistent (a SQLite file per + // event) — the local realization of the per-event log (#72). + const dataDir = mkdtempSync(join(tmpdir(), 'gridfpv-events-data-')); + director = await startContractDirector({ + token: TOKEN, + simLaps: 1, + simLapMs: 40, + env: { GRIDFPV_DATA_DIR: dataDir } + }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +/** `GET /events` → the parsed `EventMeta[]` (asserting a 200). */ +async function listEvents(): Promise { + const res = await fetch(`${director.baseUrl}/events`); + expect(res.status).toBe(200); + return (await res.json()) as EventMeta[]; +} + +/** `POST /events` with an optional bearer token → the raw status + parsed body. */ +async function createEvent( + name: string, + token?: string +): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${director.baseUrl}/events`, { + method: 'POST', + headers, + body: JSON.stringify({ name }) + }); + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + return { status: res.status, body }; +} + +describe('seam 9: events lifecycle API', () => { + it('GET /events lists the built-in Practice event first (in-memory, non-persistent)', async () => { + const events = await listEvents(); + expect(events.length).toBeGreaterThanOrEqual(1); + const first = events[0]; + expect(first.id).toBe('practice'); + expect(first.name).toBe('Practice'); + expect(first.persistent).toBe(false); + // created_at is a plain JSON number (the i64 → number contract), never a bigint/string. + expect(typeof first.created_at).toBe('number'); + }); + + it('POST /events requires the RD token — no/bad token → 401', async () => { + const anon = await createEvent('No Auth'); + expect(anon.status).toBe(401); + const bad = await createEvent('Bad Auth', 'not-a-real-token'); + expect(bad.status).toBe(401); + }); + + it('POST /events auto-generates a unique id from the name and returns its EventMeta', async () => { + const a = (await createEvent('Spring Cup 2026!', TOKEN)).body as EventMeta; + const b = (await createEvent('Spring Cup 2026!', TOKEN)).body as EventMeta; + // The id is server-generated (a name slug + suffix), never the verbatim name; two events + // with the same name get distinct ids. + expect(a.id).toMatch(/^spring-cup-2026-/); + expect(b.id).toMatch(/^spring-cup-2026-/); + expect(a.id).not.toBe(b.id); + expect(a.name).toBe('Spring Cup 2026!'); + expect(a.persistent).toBe(true); + + // The new event now appears in the listing (after Practice). + const ids = (await listEvents()).map((e) => e.id); + expect(ids[0]).toBe('practice'); + expect(ids).toContain(a.id); + }); + + it('a created event is reachable and independent of Practice', async () => { + const created = (await createEvent('Race Night', TOKEN)).body as EventMeta; + + // Schedule a heat in the created event over ITS OWN control path (`/events/{id}/control`). + const command: Command = { ScheduleHeat: { heat: 'cn-1', lineup: ['A', 'B'] } }; + const scheduled = await fetch(`${eventRoot(director.baseUrl, created.id)}/control`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify(command) + }); + expect(scheduled.status).toBe(200); + expect(((await scheduled.json()) as { ok: boolean }).ok).toBe(true); + + // The heat resolves a 200 under the created event… + const inCreated = await fetch(`${eventRoot(director.baseUrl, created.id)}/snapshot/heat/cn-1`); + expect(inCreated.status).toBe(200); + + // …but is NOT visible in Practice (per-event logs are independent). + const inPractice = await fetch(`${eventRoot(director.baseUrl)}/snapshot/heat/cn-1`); + expect(inPractice.status).toBe(404); + }); + + it('an unknown event id → 404 ProtocolError(UnknownScope)', async () => { + const res = await fetch(`${eventRoot(director.baseUrl, 'no-such-event')}/snapshot/event/x`); + expect(res.status).toBe(404); + const body = (await res.json()) as { code?: string }; + expect(body.code).toBe('UnknownScope'); + }); +}); diff --git a/frontend/contract/harness.ts b/frontend/contract/harness.ts index 529be52..cb3c252 100644 --- a/frontend/contract/harness.ts +++ b/frontend/contract/harness.ts @@ -20,6 +20,18 @@ import { import type { Command } from '@gridfpv/types'; +/** + * The built-in **Practice** event id every per-event contract route is rooted under (issue + * #72). Events are first-class containers now — `/events/{eventId}/snapshot|stream|control|…` + * — and Practice is the always-present in-memory event the contract suite drives against. + */ +export const PRACTICE_EVENT_ID = 'practice'; + +/** The `/events/practice` route root the per-event contract paths hang off. */ +export function eventRoot(baseUrl: string, eventId: string = PRACTICE_EVENT_ID): string { + return `${baseUrl}/events/${encodeURIComponent(eventId)}`; +} + /** A fresh empty dir to point `GRIDFPV_ASSETS` at, so the SPA fallback is a deterministic 404. */ export function emptyAssetsDir(): string { return mkdtempSync(join(tmpdir(), 'gridfpv-no-assets-')); @@ -58,7 +70,8 @@ export async function postControl( const headers: Record = {}; if (contentType) headers['Content-Type'] = 'application/json'; if (token !== undefined) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`${baseUrl}/control`, { + // Control is rooted under the Practice event (#72): `/events/practice/control`. + const res = await fetch(`${eventRoot(baseUrl)}/control`, { method: 'POST', headers, body: JSON.stringify(command) diff --git a/frontend/contract/race.contract.ts b/frontend/contract/race.contract.ts index 6baf2f3..376c7b8 100644 --- a/frontend/contract/race.contract.ts +++ b/frontend/contract/race.contract.ts @@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { connect } from '../packages/protocol-client/dist/index.js'; import { type Director } from '../test-harness/director.ts'; -import { driveToRunning, rdControl, startContractDirector } from './harness.ts'; +import { driveToRunning, eventRoot, rdControl, startContractDirector } from './harness.ts'; const TOKEN = 'rd-race-contract'; const HEAT = 'final'; @@ -90,7 +90,7 @@ describe('seam 8: a full race converges through the real protocol-client', () => // And the scored HeatResult is served on the read path (a result exists). const result = (await ( - await fetch(`${director.baseUrl}/snapshot/heat/${HEAT}?projection=result`) + await fetch(`${eventRoot(director.baseUrl)}/snapshot/heat/${HEAT}?projection=result`) ).json()) as { body: { HeatResult: { places: unknown[] } } }; expect(result.body.HeatResult.places.length).toBe(LINEUP.length); } finally { diff --git a/frontend/contract/snapshot.contract.ts b/frontend/contract/snapshot.contract.ts index 164ebf3..ea77a01 100644 --- a/frontend/contract/snapshot.contract.ts +++ b/frontend/contract/snapshot.contract.ts @@ -64,7 +64,7 @@ function isSnapshot(v: unknown): v is { cursor: number; body: Record { it('GET /snapshot/event/{id} → 200 LiveRaceState + numeric cursor', async () => { - const { status, json } = await getSnapshot('/snapshot/event/spring-cup'); + const { status, json } = await getSnapshot('/events/practice/snapshot/event/spring-cup'); expect(status).toBe(200); expect(isSnapshot(json)).toBe(true); const snap = json as { cursor: number; body: Record }; @@ -73,25 +73,29 @@ describe('seam 1: snapshot routes are path-scoped', () => { }); it('GET /snapshot/heat/{id} → 200 LiveRaceState (default projection)', async () => { - const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}`); + const { status, json } = await getSnapshot(`/events/practice/snapshot/heat/${HEAT}`); expect(status).toBe(200); expect(Object.keys((json as { body: object }).body)).toEqual(['LiveRaceState']); }); it('GET /snapshot/heat/{id}?projection=laps → 200 LapList', async () => { - const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=laps`); + const { status, json } = await getSnapshot( + `/events/practice/snapshot/heat/${HEAT}?projection=laps` + ); expect(status).toBe(200); expect(Object.keys((json as { body: object }).body)).toEqual(['LapList']); }); it('GET /snapshot/heat/{id}?projection=result → 200 HeatResult', async () => { - const { status, json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=result`); + const { status, json } = await getSnapshot( + `/events/practice/snapshot/heat/${HEAT}?projection=result` + ); expect(status).toBe(200); expect(Object.keys((json as { body: object }).body)).toEqual(['HeatResult']); }); it('GET /snapshot/class/{event}/{class} → 200 LiveRaceState', async () => { - const { status, json } = await getSnapshot('/snapshot/class/spring-cup/open'); + const { status, json } = await getSnapshot('/events/practice/snapshot/class/spring-cup/open'); expect(status).toBe(200); expect(Object.keys((json as { body: object }).body)).toEqual(['LiveRaceState']); }); @@ -105,7 +109,7 @@ describe('seam 1: snapshot routes are path-scoped', () => { const deadline = Date.now() + 8_000; let snap: { status: number; json: unknown } | undefined; while (Date.now() < deadline) { - snap = await getSnapshot('/snapshot/pilot/spring-cup/A'); + snap = await getSnapshot('/events/practice/snapshot/pilot/spring-cup/A'); if (snap.status === 200) break; await new Promise((r) => setTimeout(r, 50)); } @@ -115,7 +119,7 @@ describe('seam 1: snapshot routes are path-scoped', () => { it('an unknown SCOPE id → 404 ProtocolError(UnknownScope), not a silent Snapshot', async () => { // A real path with a missing id: the contract is a typed 404, not a 200 fallback. - const { status, json } = await getSnapshot('/snapshot/heat/does-not-exist'); + const { status, json } = await getSnapshot('/events/practice/snapshot/heat/does-not-exist'); expect(status).toBe(404); expect((json as { code?: string }).code).toBe('UnknownScope'); }); @@ -153,7 +157,7 @@ describe('seam 1: snapshot routes are path-scoped', () => { describe('seam 4: wire integers are JSON numbers, never bigint/string', () => { it('snapshot cursor is a number > 0 on a non-empty log', async () => { - const { json } = await getSnapshot('/snapshot/event/spring-cup'); + const { json } = await getSnapshot('/events/practice/snapshot/event/spring-cup'); const snap = json as { cursor: number }; expect(typeof snap.cursor).toBe('number'); expect(Number.isInteger(snap.cursor)).toBe(true); @@ -166,7 +170,7 @@ describe('seam 4: wire integers are JSON numbers, never bigint/string', () => { const deadline = Date.now() + 8_000; let progressed: { laps_completed: unknown; last_lap_micros?: unknown } | undefined; while (Date.now() < deadline) { - const { json } = await getSnapshot(`/snapshot/heat/${HEAT}`); + const { json } = await getSnapshot(`/events/practice/snapshot/heat/${HEAT}`); const ls = ( json as { body: { LiveRaceState: { progress?: Array> } } } ).body.LiveRaceState; @@ -186,7 +190,7 @@ describe('seam 4: wire integers are JSON numbers, never bigint/string', () => { const deadline = Date.now() + 8_000; let dur: unknown; while (Date.now() < deadline) { - const { json } = await getSnapshot(`/snapshot/heat/${HEAT}?projection=laps`); + const { json } = await getSnapshot(`/events/practice/snapshot/heat/${HEAT}?projection=laps`); const list = ( json as { body: { LapList: { competitors: Array<{ laps: Array<{ duration_micros: unknown }> }> } }; diff --git a/frontend/contract/stream.contract.ts b/frontend/contract/stream.contract.ts index 1d4356e..7df3f0c 100644 --- a/frontend/contract/stream.contract.ts +++ b/frontend/contract/stream.contract.ts @@ -19,7 +19,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { connect } from '../packages/protocol-client/dist/index.js'; import { type Director } from '../test-harness/director.ts'; -import { openSocket, rdControl, startContractDirector, waitForFrame, wsBase } from './harness.ts'; +import { + eventRoot, + openSocket, + rdControl, + startContractDirector, + waitForFrame, + wsBase +} from './harness.ts'; const TOKEN = 'rd-stream-contract'; const HEAT = 'q-1'; @@ -38,9 +45,13 @@ afterAll(async () => { await director?.stop(); }); -/** The snapshot cursor for a scope path — the `from:` a stream resumes at. */ +/** + * The snapshot cursor for a scope path — the `from:` a stream resumes at. `path` is the + * within-event snapshot path (e.g. `/snapshot/heat/q-1`); it is rooted under the Practice + * event (#72) here. + */ async function snapshotCursor(path: string): Promise { - const res = await fetch(`${director.baseUrl}${path}`); + const res = await fetch(`${eventRoot(director.baseUrl)}${path}`); const snap = (await res.json()) as { cursor: number }; return snap.cursor; } @@ -48,7 +59,7 @@ async function snapshotCursor(path: string): Promise { describe('seam 2: stream frames are externally-tagged StreamMessage', () => { it('a control append produces a `{ Change: ChangeEnvelope }` frame, not a bare envelope', async () => { const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/stream`); ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); // A heat-state change after the subscribe re-folds the scope and pushes one envelope. @@ -74,7 +85,9 @@ describe('seam 2: stream frames are externally-tagged StreamMessage', () => { Register: { adapter: 'sim', competitor: `x${i}`, pilot: `p${i}` } }); } - const { ws, frames, closed } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames, closed } = await openSocket( + `${wsBase(eventRoot(director.baseUrl))}/stream` + ); ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: 1 })); await waitForFrame(frames, (f) => f.length > 0); @@ -90,7 +103,7 @@ describe('seam 3: sequence and cursor are distinct axes; the client converges', const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); expect(cursor).toBeGreaterThan(0); // the cursor axis is well past 1 - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/stream`); ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); await rdControl(director.baseUrl, TOKEN, { Arm: { heat: HEAT } }); await waitForFrame(frames, (f) => f.length > 0); @@ -131,7 +144,9 @@ describe('seam 3: sequence and cursor are distinct axes; the client converges', describe('seam 7: contract-version negotiation', () => { it('an out-of-band contract_version → VersionMismatch refresh signal + close', async () => { - const { ws, frames, closed } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames, closed } = await openSocket( + `${wsBase(eventRoot(director.baseUrl))}/stream` + ); ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, contract_version: 999 })); await waitForFrame(frames, (f) => f.length > 0); const frame = frames[0] as { code?: string }; @@ -141,7 +156,7 @@ describe('seam 7: contract-version negotiation', () => { it('an absent contract_version subscribes and streams normally', async () => { const cursor = await snapshotCursor(`/snapshot/heat/${HEAT}`); - const { ws, frames } = await openSocket(`${wsBase(director.baseUrl)}/stream`); + const { ws, frames } = await openSocket(`${wsBase(eventRoot(director.baseUrl))}/stream`); // No contract_version field at all — treated as this build's version, streams fine. ws.send(JSON.stringify({ scope: { Heat: { heat: HEAT } }, from: cursor })); await rdControl(director.baseUrl, TOKEN, { Finish: { heat: HEAT } }); diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index 6906d36..8a0f428 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -340,4 +340,92 @@ describe('ProtocolClient', () => { client.close(); expect(client.getState().status).toBe('closed'); }); + + // ── Event-rooted surface (issue #72) ───────────────────────────────────────── + + it('roots the snapshot + stream URLs under the default Practice event', async () => { + const { fetch, calls } = mockFetch([{ cursor: 3, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + + const client = connect({ + baseUrl: 'http://director.local:8080', + scope: SCOPE, + fetch, + webSocketFactory: factory + }); + await flush(); + + // No eventId given → both the snapshot and the WS are rooted under `/events/practice`. + expect(calls[0]).toBe('http://director.local:8080/events/practice/snapshot/heat/heat-1'); + expect(sockets[0].url).toBe('ws://director.local:8080/events/practice/stream'); + + client.close(); + }); + + it('roots the URLs under an explicit eventId when given', async () => { + const { fetch, calls } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + + const client = connect({ + baseUrl: 'http://director.local:8080', + eventId: 'spring-cup-2026-ab12', + scope: SCOPE, + fetch, + webSocketFactory: factory + }); + await flush(); + + expect(calls[0]).toBe( + 'http://director.local:8080/events/spring-cup-2026-ab12/snapshot/heat/heat-1' + ); + expect(sockets[0].url).toBe('ws://director.local:8080/events/spring-cup-2026-ab12/stream'); + + client.close(); + }); +}); + +describe('events lifecycle helpers (#72)', () => { + it('listEvents GETs /events and returns the EventMeta list', async () => { + const calls: string[] = []; + const fetch: FetchLike = async (input) => { + calls.push(String(input)); + return { + ok: true, + status: 200, + json: async (): Promise => [ + { id: 'practice', name: 'Practice', created_at: 1, persistent: false } + ] + } as unknown as Response; + }; + const { listEvents } = await import('./client.js'); + const events = await listEvents('http://director.local:8080/', { fetch }); + expect(calls[0]).toBe('http://director.local:8080/events'); + expect(events[0].id).toBe('practice'); + }); + + it('createEvent POSTs the name to /events with the RD token and returns the new EventMeta', async () => { + const seen: { url: string; init?: RequestInit }[] = []; + const fetch: FetchLike = async (input, init) => { + seen.push({ url: String(input), init }); + return { + ok: true, + status: 200, + json: async (): Promise => ({ + id: 'race-night-xy99', + name: 'Race Night', + created_at: 2, + persistent: true + }) + } as unknown as Response; + }; + const { createEvent } = await import('./client.js'); + const meta = await createEvent('http://director.local:8080', 'Race Night', 'rd-tok', { fetch }); + expect(seen[0].url).toBe('http://director.local:8080/events'); + expect(seen[0].init?.method).toBe('POST'); + const headers = seen[0].init?.headers as Record; + expect(headers.Authorization).toBe('Bearer rd-tok'); + expect(JSON.parse(seen[0].init?.body as string)).toEqual({ name: 'Race Night' }); + expect(meta.id).toBe('race-night-xy99'); + expect(meta.persistent).toBe(true); + }); }); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 48c0985..7fe8c00 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -19,7 +19,10 @@ import type { ChangeEnvelope, + CreateEventRequest, Cursor, + EventId, + EventMeta, ProjectionBody, ProtocolError, Scope, @@ -78,6 +81,13 @@ export interface ConnectOptions { * is configured with the base URL *only*, so it cannot tell LAN from Cloud. */ baseUrl: string; + /** + * The **event** this connection's scope lives in (issue #72). Every read/realtime surface + * is rooted under `/events/{eventId}/…`, so the client targets one event's own log. Defaults + * to the built-in `practice` event when omitted, so an un-migrated caller still connects to + * a working event. + */ + eventId?: EventId; /** The resource this connection is scoped to (protocol.html §4). */ scope: Scope; /** Optional bearer token (sent as `Authorization: Bearer …` and on the WS URL). */ @@ -154,25 +164,78 @@ function toWebSocketBase(baseUrl: string): string { const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s); +/** The event-root prefix every per-event surface lives under (issue #72): `/events/{id}`. */ +function eventRoot(eventId: string): string { + return `/events/${encodeURIComponent(eventId)}`; +} + /** - * Build the snapshot path for a scope. The server addresses snapshots by PATH - * (`/snapshot/event/{id}`, `/snapshot/heat/{id}`, `/snapshot/class/{event}/{class}`, - * `/snapshot/pilot/{event}/{pilot}` — protocol.html §4 endpoint surface), NOT a - * `?scope=` query, so the client maps the scope to that path. + * Build the snapshot path for a scope **within an event** (issue #72). The server addresses + * snapshots by PATH, now rooted under the event: `/events/{eventId}/snapshot/event/{id}`, + * `…/snapshot/heat/{id}`, `…/snapshot/class/{event}/{class}`, `…/snapshot/pilot/{event}/{pilot}` + * (protocol.html §4 endpoint surface), NOT a `?scope=` query — so the client maps the scope to + * that path under the resolved event. */ -function snapshotPath(scope: Scope): string { - if ('Event' in scope) return `/snapshot/event/${encodeURIComponent(scope.Event.event)}`; - if ('Heat' in scope) return `/snapshot/heat/${encodeURIComponent(scope.Heat.heat)}`; +function snapshotPath(eventId: string, scope: Scope): string { + const root = `${eventRoot(eventId)}/snapshot`; + if ('Event' in scope) return `${root}/event/${encodeURIComponent(scope.Event.event)}`; + if ('Heat' in scope) return `${root}/heat/${encodeURIComponent(scope.Heat.heat)}`; if ('Class' in scope) { - return `/snapshot/class/${encodeURIComponent(scope.Class.event)}/${encodeURIComponent( + return `${root}/class/${encodeURIComponent(scope.Class.event)}/${encodeURIComponent( scope.Class.class )}`; } - return `/snapshot/pilot/${encodeURIComponent(scope.Pilot.event)}/${encodeURIComponent( + return `${root}/pilot/${encodeURIComponent(scope.Pilot.event)}/${encodeURIComponent( scope.Pilot.pilot )}`; } +/** The built-in Practice event id — the default the client connects to when none is given. */ +export const PRACTICE_EVENT_ID = 'practice'; + +/** + * List every event the server knows (`GET /events`) — issue #72. Reads are open on the LAN, + * so no token is needed; an optional token is sent when present. Resolves to the events' + * {@link EventMeta} (Practice first), or rejects on a transport/HTTP failure. + */ +export async function listEvents( + baseUrl: string, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}/events`, { headers }); + if (!resp.ok) throw new Error(`GET /events failed: HTTP ${resp.status}`); + return (await resp.json()) as EventMeta[]; +} + +/** + * Create a new event (`POST /events`) — issue #72. RD-gated: a valid RD `token` is required + * (the id is auto-generated server-side; the body carries only the display `name`). Resolves + * to the new event's {@link EventMeta}, or rejects on a non-2xx / transport failure. + */ +export async function createEvent( + baseUrl: string, + name: string, + token: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const body: CreateEventRequest = { name }; + const resp = await fetchImpl(`${trimSlash(baseUrl)}/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }); + if (!resp.ok) throw new Error(`POST /events failed: HTTP ${resp.status}`); + return (await resp.json()) as EventMeta; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * @@ -193,6 +256,8 @@ export function connect(options: ConnectOptions): ProtocolClient { const wsBase = trimSlash(toWebSocketBase(options.baseUrl)); const scope = options.scope; const token = options.token; + // The event this connection is rooted under (issue #72); defaults to Practice. + const eventId = options.eventId ?? PRACTICE_EVENT_ID; // ── Mutable connection state ─────────────────────────────────────────────── let body: ProjectionBody | undefined; @@ -240,7 +305,7 @@ export function connect(options: ConnectOptions): ProtocolClient { if (token) headers.Authorization = `Bearer ${token}`; let resp: Response; try { - resp = await fetchImpl(`${baseUrl}${snapshotPath(scope)}`, { headers }); + resp = await fetchImpl(`${baseUrl}${snapshotPath(eventId, scope)}`, { headers }); } catch (e) { if (gen !== generation || closed) return false; fail({ code: 'Internal', message: `snapshot fetch failed: ${String(e)}` }); @@ -306,7 +371,10 @@ export function connect(options: ConnectOptions): ProtocolClient { // reset our tracker to accept it from the top. streamSeq = 0; setStatus('subscribing'); - const url = token ? `${wsBase}/stream?token=${encodeURIComponent(token)}` : `${wsBase}/stream`; + const streamPath = `${eventRoot(eventId)}/stream`; + const url = token + ? `${wsBase}${streamPath}?token=${encodeURIComponent(token)}` + : `${wsBase}${streamPath}`; let socket: WebSocketLike; try { socket = wsFactory(url); diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 06e4161..a2c1261 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -12,7 +12,7 @@ * `@gridfpv/types`; this package hand-writes no wire shape of its own. */ -export { connect } from './client.js'; +export { connect, listEvents, createEvent, PRACTICE_EVENT_ID } from './client.js'; export type { ConnectOptions, ProtocolClient, diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index b8060d8..0cf3039 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -36,10 +36,12 @@ export type * from '@bindings/CompetitorLaps'; export type * from '@bindings/CompetitorRef'; export type * from '@bindings/CompletedHeat'; export type * from '@bindings/ContractVersion'; +export type * from '@bindings/CreateEventRequest'; export type * from '@bindings/Cursor'; export type * from '@bindings/ErrorCode'; export type * from '@bindings/Event'; export type * from '@bindings/EventId'; +export type * from '@bindings/EventMeta'; export type * from '@bindings/EventOutcome'; export type * from '@bindings/GateIndex'; export type * from '@bindings/HeatId'; From fd6f29b13f7f52758d82f945cc5a5dc1cbb1dd0b Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 20:32:45 +0000 Subject: [PATCH 048/362] Slice 1b: event-picker landing (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the RD console around events being the outer container: the event picker is now the landing screen, and the old Login screen dissolves. - EventPicker landing screen: on load `listEvents()` (open, no token) renders Practice prominently + the list of created events (name, created date, persistent/ephemeral badge); each enters that event. "+ New event" opens a name Dialog → createEvent(name) → enters it. Loading/empty/error states. - Session current-event model: track the selected EventMeta; selectEvent() re-homes both seams under /events/{id}/…, leaveEvent() returns to the picker. Switching events re-subscribes the read client and rebuilds the control client. Base URL defaults to the page's own origin. - App shell: picker is the landing (no event selected); selecting one shows the event workspace. Topbar shows the current event name + a control to switch event; a settings/gear sets/clears the RD token up front. - Lazy auth (loopback-trust-ready): reading/browsing needs no token; the RD token is requested only when needed — on create event or the first control action — via a TokenDialog registered as the session's token provider, then reused for the session. Loopback-trust is a later slice (not built here). - Remove the old Login screen. - e2e: navigate the new entry (load → picker → enter Practice → provide the RD token at the lazy prompt → run the heat). Unit + contract suites updated/green. Part of #72. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 212 ++++++-- .../apps/rd-console/src/lib/session.svelte.ts | 255 +++++++--- .../rd-console/src/screens/EventPicker.svelte | 455 ++++++++++++++++++ .../apps/rd-console/src/screens/Login.svelte | 148 ------ .../rd-console/src/screens/TokenDialog.svelte | 88 ++++ .../rd-console/tests/session.svelte.test.ts | 151 +++++- frontend/apps/rd-console/tests/support.ts | 15 +- frontend/e2e/race.spec.ts | 41 +- 8 files changed, 1068 insertions(+), 297 deletions(-) create mode 100644 frontend/apps/rd-console/src/screens/EventPicker.svelte delete mode 100644 frontend/apps/rd-console/src/screens/Login.svelte create mode 100644 frontend/apps/rd-console/src/screens/TokenDialog.svelte diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 094693c..ad6f739 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -1,22 +1,25 @@ -{#if !session.authenticated} +{#if !session.currentEvent}
      - +
      {:else}
      @@ -132,22 +162,43 @@ {session.connectionStatus}
      {session.baseUrl} - +
      - {eventLabel} +

      {activeScreen?.label}

      {#if liveHeat} Heat {liveHeat} {/if}
      -
      +
      +
      @@ -177,9 +228,37 @@
    - {/if} + + + + +
    + + + +

    + A token is only needed for privileged actions (creating an event, running a heat, registering, + marshaling). You'll be asked automatically when one is required. +

    +
    + {#snippet footer()} + {#if session.hasToken} + + {/if} + + {/snippet} +
    + + + diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index a01b945..5fcafa5 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -1,27 +1,42 @@ /** - * The console session: auth + the two connection seams (#51). + * The console session: the current **event** + the two connection seams (#51, #72). * - * The RD authenticates with a bearer token (protocol.html §9.4). Per the brief the - * token lives in memory, mirrored to `sessionStorage` so a tab reload inside the same - * session keeps the RD signed in but it never persists to disk. That one token feeds - * both seams: + * Slice 1b reshapes the session around events being the outer container (issue #72): + * the console no longer logs into an *address* — the Director is the page's own origin, + * defaulted from `location.origin`. What's left of auth is just the **RD token**, and + * that is now requested **lazily**: reading the event list and browsing an event need no + * token; the token is only obtained on a privileged action (create event, or a control + * command). This is forward-compatible with the loopback-trust auth slice — when that + * lands, the lazy ask simply never fires on localhost. * - * • reads — `@gridfpv/protocol-client`'s `connect({ baseUrl, scope, token })` - * (snapshot + WS stream; the console's whole live view). - * • writes — `createControlClient(baseUrl, token)` (the privileged control path). + * Lifecycle: + * + * • No event selected → the app is at the **event picker** (`currentEvent === undefined`). + * • `selectEvent(meta)` roots both seams under `/events/{id}/…`: + * - reads — `@gridfpv/protocol-client`'s `connect({ baseUrl, eventId, scope })` + * (snapshot + WS stream; the console's whole live view). Open, no token. + * - writes — `createControlClient(baseUrl, token, { eventId })` (the privileged path), + * (re)built whenever the token becomes available. + * • `leaveEvent()` tears both seams down and returns to the picker. + * + * The RD token lives in memory, mirrored to `sessionStorage` so a tab reload inside the + * same session keeps it, but it never persists to disk. A {@link tokenProvider} (set by + * the shell) lets {@link send}/{@link createEventAndEnter} prompt for the token only when + * a privileged action actually needs one. * * Everything reactive is Svelte 5 runes so screens read `session.connectionStatus`, - * `session.liveState`, etc. directly. The protocol client is framework-agnostic, so - * we bridge its `onState` callback into a `$state` field here. + * `session.liveState`, etc. directly. The protocol client is framework-agnostic, so we + * bridge its `onState` callback into a `$state` field here. */ -import { connect } from '@gridfpv/protocol-client'; +import { connect, listEvents, createEvent, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; import { createControlClient } from './control.js'; import type { ControlClient } from './control.js'; import type { Command, CommandAck, + EventMeta, HeatId, HeatResult, LiveRaceState, @@ -29,44 +44,38 @@ import type { Scope } from '@gridfpv/types'; -const STORAGE_KEY = 'gridfpv.rd.session'; +const TOKEN_STORAGE_KEY = 'gridfpv.rd.token'; /** - * The built-in **Practice** event id the console connects to by default (issue #72). Events - * are now first-class containers — every read/realtime/control surface is rooted under one. - * The full startup/event-picker UI is a follow-up PR; until then the console defaults to the - * always-present Practice event so the whole stack works end to end. + * Asks the RD for a control token. Returns the entered token, or `undefined` if the + * RD cancelled. The shell wires this to a token `Dialog`; the session calls it lazily + * the first time a privileged action needs a token it doesn't already hold. */ -const PRACTICE_EVENT_ID = 'practice'; - -interface StoredSession { - baseUrl: string; - token: string; -} +export type TokenProvider = () => Promise; -function loadStored(): StoredSession | null { +function loadStoredToken(): string | undefined { try { - const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.baseUrl === 'string' && typeof parsed.token === 'string') { - return { baseUrl: parsed.baseUrl, token: parsed.token }; - } + const raw = globalThis.sessionStorage?.getItem(TOKEN_STORAGE_KEY); + return raw ?? undefined; } catch { - /* ignore malformed storage */ + return undefined; } - return null; } -function persist(s: StoredSession | null): void { +function persistToken(token: string | undefined): void { try { - if (s) globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(s)); - else globalThis.sessionStorage?.removeItem(STORAGE_KEY); + if (token) globalThis.sessionStorage?.setItem(TOKEN_STORAGE_KEY, token); + else globalThis.sessionStorage?.removeItem(TOKEN_STORAGE_KEY); } catch { /* storage unavailable (private mode / SSR) — in-memory still works */ } } +/** The default base URL: the page's own origin (the Director serves the console). */ +function defaultBaseUrl(): string { + return globalThis.location?.origin || 'http://localhost:8080'; +} + /** Pull the `LiveRaceState` out of a projection body, if that's what it carries. */ function liveStateOf(body: ProjectionBody | undefined): LiveRaceState | undefined { if (body && 'LiveRaceState' in body) return body.LiveRaceState; @@ -75,11 +84,17 @@ function liveStateOf(body: ProjectionBody | undefined): LiveRaceState | undefine /** The reactive console session — one instance, shared across screens. */ export class Session { - /** Whether the RD has signed in (a token is held). */ - authenticated = $state(false); - /** The base URL the RD connected to. */ - baseUrl = $state(''); - /** The live read-client's lifecycle status (connecting → live → …). */ + /** + * The event the console is currently inside (#72), or `undefined` when the RD is at + * the **event picker** (the landing screen). Selecting an event roots both seams + * under it; leaving returns here. + */ + currentEvent = $state.raw(undefined); + /** The base URL both seams target — the Director's origin. */ + baseUrl = $state(defaultBaseUrl()); + /** Whether an RD token is currently held (drives the settings/gear state). */ + hasToken = $state(false); + /** The live read-client's lifecycle status (connecting → live → …); `idle` at the picker. */ connectionStatus = $state('idle'); /** * The latest full protocol state (body + cursor + status + error). `$state.raw`: @@ -101,51 +116,96 @@ export class Session { // Non-reactive internals. #token: string | undefined; - /** The event every surface is rooted under (#72); defaults to the built-in Practice event. */ - #eventId: string = PRACTICE_EVENT_ID; #client: ProtocolClient | undefined; #control: ControlClient | undefined; #unsub: (() => void) | undefined; + /** The shell's lazy token prompt (a `Dialog`); set via {@link setTokenProvider}. */ + #tokenProvider: TokenProvider | undefined; // Injectable for tests so the session never opens a real socket. #connectImpl: typeof connect; #controlFactory: typeof createControlClient; + #listEventsImpl: typeof listEvents; + #createEventImpl: typeof createEvent; constructor(opts?: { connectImpl?: typeof connect; controlFactory?: typeof createControlClient; + listEventsImpl?: typeof listEvents; + createEventImpl?: typeof createEvent; + baseUrl?: string; autoRestore?: boolean; }) { this.#connectImpl = opts?.connectImpl ?? connect; this.#controlFactory = opts?.controlFactory ?? createControlClient; + this.#listEventsImpl = opts?.listEventsImpl ?? listEvents; + this.#createEventImpl = opts?.createEventImpl ?? createEvent; + if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { - const stored = loadStored(); - if (stored) this.login(stored.baseUrl, stored.token); + const stored = loadStoredToken(); + if (stored) { + this.#token = stored; + this.hasToken = true; + } + } + } + + /** Install the shell's lazy token prompt. Called once by the app shell. */ + setTokenProvider(provider: TokenProvider): void { + this.#tokenProvider = provider; + } + + /** Set (or replace) the held RD token — e.g. from the settings/gear. */ + setToken(token: string): void { + const trimmed = token.trim(); + if (!trimmed) return; + this.#token = trimmed; + this.hasToken = true; + persistToken(trimmed); + // Re-home the control client so it carries the new token, if inside an event. + if (this.currentEvent) { + this.#control = this.#controlFactory(this.baseUrl, this.#token, { + eventId: this.currentEvent.id + }); + } + } + + /** Forget the held RD token (settings/gear "clear"). Reads keep working (open). */ + clearToken(): void { + this.#token = undefined; + this.hasToken = false; + persistToken(undefined); + if (this.currentEvent) { + this.#control = this.#controlFactory(this.baseUrl, undefined, { + eventId: this.currentEvent.id + }); } } /** - * Sign in: hold the token, open the control client, and connect the live read - * client to the event scope so the console has a live view from the start. + * List the events the Director knows (`GET /events`) — open, no token. Used by the + * event picker on load. Rejects on a transport/HTTP failure (the picker shows it). */ - login(baseUrl: string, token: string, scope?: Scope): void { - this.logout(false); - this.#token = token; - this.baseUrl = baseUrl; - this.authenticated = true; - // Control is rooted under the current event (#72) — default Practice. - this.#control = this.#controlFactory(baseUrl, token, { eventId: this.#eventId }); - persist({ baseUrl, token }); - - // Default to the whole (Practice) event so the console has a live view from login. The - // event id is the built-in Practice event (#72); the startup/event-picker UI that lets - // the RD choose another event is a follow-up PR. - const liveScope: Scope = scope ?? { Event: { event: this.#eventId } }; + listEvents(): Promise { + return this.#listEventsImpl(this.baseUrl, { token: this.#token }); + } + + /** + * Enter an event (#72): root both seams under `/events/{id}/…` and open the live read + * stream (open, no token). The control client is built with whatever token is held; + * if none, the first privileged {@link send} will lazily obtain one. + */ + selectEvent(meta: EventMeta, scope?: Scope): void { + this.leaveEvent(); + this.currentEvent = meta; + this.#control = this.#controlFactory(this.baseUrl, this.#token, { eventId: meta.id }); + + const liveScope: Scope = scope ?? { Event: { event: meta.id } }; this.connectionStatus = 'connecting'; this.#client = this.#connectImpl({ - baseUrl, - eventId: this.#eventId, + baseUrl: this.baseUrl, + eventId: meta.id, scope: liveScope, - token + token: this.#token }); this.#unsub = this.#client.onState((state) => { this.protocolState = state; @@ -154,15 +214,16 @@ export class Session { }); } - /** Re-scope the live read client (e.g. once the event id is known). */ + /** Re-scope the live read client within the current event (e.g. to a heat scope). */ resubscribe(scope: Scope): void { - if (!this.authenticated || !this.#token) return; + const event = this.currentEvent; + if (!event) return; this.#unsub?.(); this.#client?.close(); this.connectionStatus = 'connecting'; this.#client = this.#connectImpl({ baseUrl: this.baseUrl, - eventId: this.#eventId, + eventId: event.id, scope, token: this.#token }); @@ -173,32 +234,68 @@ export class Session { }); } - /** Sign out and tear down both seams. */ - logout(clearStorage = true): void { + /** Leave the current event and return to the picker; tears the read seam down. */ + leaveEvent(): void { this.#unsub?.(); this.#unsub = undefined; this.#client?.close(); this.#client = undefined; this.#control = undefined; - this.#token = undefined; - this.authenticated = false; + this.currentEvent = undefined; this.connectionStatus = 'idle'; this.protocolState = undefined; this.liveState = undefined; this.heatResult = undefined; this.lastCommandError = undefined; - if (clearStorage) persist(null); } /** - * Send a privileged command through the control client, recording any error for - * the UI. Returns the raw `CommandAck` so callers can branch on success too. + * Ensure a token is held, prompting for one lazily if not. Returns `true` once a token + * is present, `false` if the RD cancelled the prompt. The control client is rebuilt to + * carry a freshly-obtained token. + */ + async #ensureToken(): Promise { + if (this.#token) return true; + if (!this.#tokenProvider) return false; + const entered = await this.#tokenProvider(); + if (!entered?.trim()) return false; + this.setToken(entered); + return true; + } + + /** + * Create a persistent event by name (`POST /events`, RD-gated) and enter it. Obtains + * the RD token lazily if none is held. Returns the new {@link EventMeta}, or throws on + * a transport/HTTP failure, or `undefined` if the RD cancelled the token prompt. + */ + async createEventAndEnter(name: string): Promise { + if (!(await this.#ensureToken()) || !this.#token) return undefined; + const meta = await this.#createEventImpl(this.baseUrl, name, this.#token); + this.selectEvent(meta); + return meta; + } + + /** + * Send a privileged command through the control client, recording any error for the UI. + * Obtains the RD token lazily first (issue #72): a control action is the moment auth is + * actually needed, so if no token is held the shell's token prompt fires; cancelling + * yields an `Unauthorized` ack. Returns the raw `CommandAck` so callers branch on success. */ async send(command: Command): Promise { if (!this.#control) { const ack: CommandAck = { ok: false, - error: { code: 'Unauthorized', message: 'Not signed in.' } + error: { code: 'Unauthorized', message: 'No event selected.' } + }; + this.lastCommandError = ack.error; + return ack; + } + // Lazy auth: only reach for the token (and its async prompt) when none is held — a + // held token sends immediately, keeping the common path a single await. + if (!this.#token && !(await this.#ensureToken())) { + const ack: CommandAck = { + ok: false, + error: { code: 'Unauthorized', message: 'A control token is required for this action.' } }; this.lastCommandError = ack.error; return ack; @@ -214,18 +311,20 @@ export class Session { } /** - * Pull a heat's scored result (`GET /snapshot/heat/{heat}?projection=result`) and - * store it on {@link heatResult} for the Results screen. The live read stream only - * carries `LiveRaceState`; the scored `HeatResult` is a separate heat-scope read. A - * non-2xx or malformed body leaves `heatResult` unchanged. + * Pull a heat's scored result (`GET /events/{event}/snapshot/heat/{heat}?projection=result`) + * and store it on {@link heatResult} for the Results screen. The live read stream only + * carries `LiveRaceState`; the scored `HeatResult` is a separate heat-scope read. A non-2xx + * or malformed body leaves `heatResult` unchanged. */ async fetchHeatResult(heat: HeatId): Promise { + const event = this.currentEvent; + if (!event) return undefined; const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl; const headers: Record = {}; if (this.#token) headers.Authorization = `Bearer ${this.#token}`; try { const resp = await globalThis.fetch( - `${base}/events/${encodeURIComponent(this.#eventId)}/snapshot/heat/${encodeURIComponent( + `${base}/events/${encodeURIComponent(event.id)}/snapshot/heat/${encodeURIComponent( heat )}?projection=result`, { headers } @@ -249,3 +348,5 @@ export class Session { return undefined; } } + +export { PRACTICE_EVENT_ID }; diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte new file mode 100644 index 0000000..5c7af54 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -0,0 +1,455 @@ + + +
    +
    +
    +
    + +
    + GridFPV + Race Director Console +
    +
    + +
    + +

    Choose an event

    +

    + An event is the container for everything you do. Pick Practice to jump straight + in, open one you've created, or start a new event. +

    + + {#if loadState.kind === 'loading'} +
    + + Loading events… +
    + {:else if loadState.kind === 'error'} + +
    +

    Couldn't reach the Director.

    + {loadState.message} + +
    +
    + {:else} + {#if practice} +
    + +
    + {/if} + +
    +

    Your events

    + {#if others.length === 0} +
    +

    No events yet.

    +

    Create one to keep heats, registration, and results.

    + +
    + {:else} +
      + {#each others as ev (ev.id)} +
    • + +
    • + {/each} +
    + {/if} +
    + {/if} +
    +
    + + (newError = undefined)}> +
    + + + +

    + A persistent event keeps its heats, registration, and results. The id is generated for you. +

    +
    + {#snippet footer()} + + + {/snippet} +
    + + diff --git a/frontend/apps/rd-console/src/screens/Login.svelte b/frontend/apps/rd-console/src/screens/Login.svelte deleted file mode 100644 index 862c627..0000000 --- a/frontend/apps/rd-console/src/screens/Login.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - - - - diff --git a/frontend/apps/rd-console/src/screens/TokenDialog.svelte b/frontend/apps/rd-console/src/screens/TokenDialog.svelte new file mode 100644 index 0000000..fc0aaaf --- /dev/null +++ b/frontend/apps/rd-console/src/screens/TokenDialog.svelte @@ -0,0 +1,88 @@ + + + +
    +

    + This action writes to the event, so it needs your RD control token. It's kept + for this session only and never saved to disk. +

    + + + +
    + {#snippet footer()} + + + {/snippet} +
    + + diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index f45869e..39eb683 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; -import type { CommandAck } from '@gridfpv/types'; +import type { CommandAck, EventMeta } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; import { liveRunning, okAck, failAck } from './fixtures.js'; +const PRACTICE: EventMeta = { id: 'practice', name: 'Practice', created_at: 0, persistent: false }; +const EVENT_A: EventMeta = { id: 'evt-a', name: 'Friday Series', created_at: 1, persistent: true }; + /** A mock ProtocolClient that lets a test push state into the session. */ function mockConnect(initial: ProtocolState) { let listener: StateListener | undefined; @@ -23,24 +26,30 @@ function mockConnect(initial: ProtocolState) { return { connect, client, push }; } +const connecting: ProtocolState = { + body: undefined, + cursor: undefined, + status: 'connecting', + error: undefined +}; + describe('Session', () => { - it('login holds auth, opens both seams, and surfaces live state from the stream', () => { - const { connect, push } = mockConnect({ - body: undefined, - cursor: undefined, - status: 'connecting', - error: undefined - }); + it('starts at the picker (no event) and only connects once an event is selected', () => { + const { connect, push } = mockConnect(connecting); const control = { baseUrl: 'http://d.local', sendCommand: vi.fn(async () => okAck) }; const controlFactory = vi.fn(() => control); const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); - session.login('http://d.local', 'tok'); + expect(session.currentEvent).toBeUndefined(); + expect(connect).not.toHaveBeenCalled(); - expect(session.authenticated).toBe(true); + session.selectEvent(PRACTICE); + expect(session.currentEvent?.id).toBe('practice'); expect(connect).toHaveBeenCalledOnce(); - // The control client is rooted under the default Practice event (#72). - expect(controlFactory).toHaveBeenCalledWith('http://d.local', 'tok', { eventId: 'practice' }); + // Both seams are rooted under the selected event (#72). + expect(controlFactory).toHaveBeenCalledWith(session.baseUrl, undefined, { + eventId: 'practice' + }); // Stream pushes a LiveRaceState body → session.liveState reflects it. push({ body: { LiveRaceState: liveRunning }, cursor: 1, status: 'live', error: undefined }); @@ -48,18 +57,70 @@ describe('Session', () => { expect(session.liveState?.current_heat).toBe('heat-1'); }); - it('send routes a Command through the control client and records errors', async () => { - const { connect } = mockConnect({ - body: undefined, - cursor: undefined, - status: 'live', - error: undefined + it('switching events re-homes both seams to the new event', () => { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + + session.selectEvent(PRACTICE); + session.selectEvent(EVENT_A); + expect(session.currentEvent?.id).toBe('evt-a'); + // The latest connect/control target the new event. + expect(connect).toHaveBeenLastCalledWith(expect.objectContaining({ eventId: 'evt-a' })); + expect(controlFactory).toHaveBeenLastCalledWith(session.baseUrl, undefined, { + eventId: 'evt-a' }); + }); + + it('send prompts for the token lazily the first time, then reuses it', async () => { + const { connect } = mockConnect(connecting); + const sendCommand = vi.fn(async (): Promise => okAck); + const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.setTokenProvider(tokenProvider); + session.selectEvent(PRACTICE); + expect(session.hasToken).toBe(false); + + const ack = await session.send({ Stage: { heat: 'heat-1' } }); + expect(ack.ok).toBe(true); + expect(tokenProvider).toHaveBeenCalledOnce(); + expect(session.hasToken).toBe(true); + expect(sendCommand).toHaveBeenCalledWith({ Stage: { heat: 'heat-1' } }); + + // Second send reuses the held token — no second prompt. + await session.send({ Arm: { heat: 'heat-1' } }); + expect(tokenProvider).toHaveBeenCalledOnce(); + }); + + it('send returns Unauthorized when the RD cancels the token prompt', async () => { + const { connect } = mockConnect(connecting); + const sendCommand = vi.fn(async (): Promise => okAck); + const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); + const tokenProvider = vi.fn(async () => undefined); // cancelled + + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.setTokenProvider(tokenProvider); + session.selectEvent(PRACTICE); + + const ack = await session.send({ Stage: { heat: 'heat-1' } }); + expect(ack.ok).toBe(false); + expect(ack.error?.code).toBe('Unauthorized'); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it('send routes a Command and records control errors when a token is held', async () => { + const { connect } = mockConnect(connecting); const sendCommand = vi.fn(async (): Promise => failAck); const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); - session.login('http://d.local', 'tok'); + session.setToken('tok'); + session.selectEvent(PRACTICE); const ack = await session.send({ Stage: { heat: 'heat-1' } }); expect(sendCommand).toHaveBeenCalledWith({ Stage: { heat: 'heat-1' } }); @@ -70,14 +131,36 @@ describe('Session', () => { expect(session.lastCommandError).toBeUndefined(); }); - it('refuses to send when not signed in', async () => { + it('refuses to send when no event is selected', async () => { const session = new Session({ autoRestore: false }); const ack = await session.send({ Stage: { heat: 'h' } }); expect(ack.ok).toBe(false); expect(ack.error?.code).toBe('Unauthorized'); }); - it('logout tears down the read client and clears state', () => { + it('createEventAndEnter creates by name and enters the new event', async () => { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const createEventImpl = vi.fn(async () => EVENT_A); + + const session = new Session({ + connectImpl: connect, + controlFactory, + createEventImpl, + autoRestore: false + }); + session.setToken('tok'); + + const meta = await session.createEventAndEnter('Friday Series'); + expect(createEventImpl).toHaveBeenCalledWith(session.baseUrl, 'Friday Series', 'tok'); + expect(meta?.id).toBe('evt-a'); + expect(session.currentEvent?.id).toBe('evt-a'); + }); + + it('leaveEvent tears the read client down and returns to the picker', () => { const { connect, client } = mockConnect({ body: undefined, cursor: undefined, @@ -89,11 +172,31 @@ describe('Session', () => { sendCommand: vi.fn(async () => okAck) })); const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); - session.login('http://d.local', 'tok'); + session.selectEvent(PRACTICE); - session.logout(); + session.leaveEvent(); expect(client.close).toHaveBeenCalled(); - expect(session.authenticated).toBe(false); + expect(session.currentEvent).toBeUndefined(); + expect(session.connectionStatus).toBe('idle'); expect(session.liveState).toBeUndefined(); }); + + it('clearToken keeps reads working and rebuilds the control client tokenless', () => { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.setToken('tok'); + session.selectEvent(PRACTICE); + expect(session.hasToken).toBe(true); + + session.clearToken(); + expect(session.hasToken).toBe(false); + // Control was rebuilt without a token; reads (the connect call) are untouched. + expect(controlFactory).toHaveBeenLastCalledWith(session.baseUrl, undefined, { + eventId: 'practice' + }); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 66bdaf0..4d71512 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -5,9 +5,17 @@ */ import { vi } from 'vitest'; import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; -import type { Command, CommandAck, LiveRaceState } from '@gridfpv/types'; +import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; +/** The built-in Practice event the screen tests render inside. */ +const PRACTICE: EventMeta = { + id: 'practice', + name: 'Practice', + created_at: 0, + persistent: false +}; + export interface TestSession { session: Session; sendSpy: ReturnType Promise>>; @@ -40,9 +48,12 @@ export function makeTestSession(opts?: { ack?: CommandAck; live?: LiveRaceState const session = new Session({ connectImpl: () => client, controlFactory: () => ({ baseUrl: 'http://d.local', sendCommand: sendSpy }), + baseUrl: 'http://d.local', autoRestore: false }); - session.login('http://d.local', 'tok'); + // Seed a token (so privileged sends don't trigger the lazy prompt) and enter Practice. + session.setToken('tok'); + session.selectEvent(PRACTICE); const pushLive = (state: LiveRaceState) => listener?.({ body: { LiveRaceState: state }, cursor: 1, status: 'live', error: undefined }); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 1507c91..08c1ee6 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -1,12 +1,13 @@ /** * Full RD console click-through against a real Director (#13, v0.4 Director wiring + - * observability harness). + * observability harness; entry reshaped for the event-picker landing, #72 Slice 1b). * - * This is the deliverable proof: a person opens the RD console, logs in, defines a heat - * with named pilots, runs it, **watches the live laps climb in the rendered DOM**, finishes - * + scores, and reads results with the pilots and their lap counts — every step a real - * click/input in headless chromium, every command on the real control path, every lap from - * the real built-in sim source. Nothing is mocked. + * This is the deliverable proof: a person opens the RD console, lands on the **event + * picker**, enters **Practice**, defines a heat with named pilots, runs it (providing the + * RD token when the lazy prompt fires on the first control action), **watches the live laps + * climb in the rendered DOM**, finishes + scores, and reads results with the pilots and + * their lap counts — every step a real click/input in headless chromium, every command on + * the real control path, every lap from the real built-in sim source. Nothing is mocked. * * The lap-counts-climbing assertion is the load-bearing one: it polls the per-pilot lap * numbers rendered in the HeatSheet and asserts they increase over a couple of seconds — @@ -25,16 +26,20 @@ import { RD_TOKEN } from '../playwright.config.js'; const PILOTS = ['Ace', 'Bee', 'Cee']; const HEAT_ID = 'q-1'; -test('RD drives a full basic sim race through the console UI', async ({ page, baseURL }) => { - const base = baseURL!; - - // ── Open the console ───────────────────────────────────────────────────────────────── +test('RD drives a full basic sim race through the console UI', async ({ page }) => { + // ── Open the console: the event picker is the landing screen (#72) ─────────────────── await page.goto('/'); - // ── Log in with the Director address + the known RD token ──────────────────────────── - await page.getByLabel('Director address').fill(base); - await page.getByLabel('Control token').fill(RD_TOKEN); - await page.getByRole('button', { name: 'Sign in' }).click(); + // ── Enter the always-present Practice event (no token needed to browse/enter) ──────── + // The picker reads the open event list and renders Practice prominently; clicking it + // enters the event workspace. The Director is the page's own origin — no address to type. + await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ + timeout: 15_000 + }); + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); // The shell is up: the Live control screen is the default landing screen. await expect(page.getByRole('button', { name: /Live control/ })).toBeVisible(); @@ -51,8 +56,14 @@ test('RD drives a full basic sim race through the console UI', async ({ page, ba await newHeat.getByLabel(`Pilot ${i + 1} name`).fill(PILOTS[i]); } - // ── Schedule it ────────────────────────────────────────────────────────────────────── + // ── Schedule it: this is the first control action, so the lazy RD-token prompt fires ── await newHeat.getByRole('button', { name: 'Schedule heat' }).click(); + // Provide the known RD token in the lazy prompt; once entered, the action proceeds and + // the token is reused for every subsequent control action this session. + const tokenPrompt = page.getByRole('form', { name: 'Control token' }); + await expect(tokenPrompt).toBeVisible(); + await tokenPrompt.getByLabel('Control token').fill(RD_TOKEN); + await page.getByRole('button', { name: 'Use token' }).click(); // The heat lands on the timer: current heat + the lineup show, phase Scheduled. await expect(page.locator('.heat-id .value')).toHaveText(HEAT_ID); From 515ffeb2fdd96e922eecfcd2b142d1255a9a39f8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:02:41 +0000 Subject: [PATCH 049/362] Full-trust control auth by default; lazy prompt only on 401 (#72, B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control auth becomes optional. When no RD token is configured the control path is OPEN (full-trust, safe on loopback / trusted LAN); the moment a token is configured, control is gated again (original behavior preserved). Backend: - TokenStore::has_control_credential() + authenticate_control() admits an anonymous caller when nothing is configured; a present token is always validated; a configured credential + no token → 401. Read-only/join tokens never count as a control credential. - Director (main.rs) registers a token ONLY when GRIDFPV_RD_TOKEN is set; otherwise it boots open and prints "control is OPEN". Frontend: - createEvent token is now optional (no Authorization header when absent). - Session.send / createEventAndEnter send WITHOUT a token first; only a 401 (Unauthorized ack / HTTP 401 on create) opens the lazy TokenDialog, then the action is retried once and the token reused for the session. Against an open Director there is no prompt ever. The production loopback-trust + remote-passphrase split is #80 (not built here). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/main.rs | 45 +++++-- crates/server/src/app.rs | 33 +++-- crates/server/src/auth.rs | 126 +++++++++++++++--- .../apps/rd-console/src/lib/session.svelte.ts | 98 ++++++++++---- .../rd-console/tests/session.svelte.test.ts | 108 ++++++++++++++- .../protocol-client/src/client.test.ts | 33 +++++ .../packages/protocol-client/src/client.ts | 27 ++-- 7 files changed, 382 insertions(+), 88 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index d55c482..ed1c30b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -43,16 +43,17 @@ async fn serve() -> Result<(), Box> { // events created via `POST /events` get a SQLite file under the configured data dir. let registry = EventRegistry::new(config.data_dir.clone())?; - // Mint (or pin) the RD's control token up front and print it with the URL so the RD - // console (or the Tauri app) can log straight in. The store is Director-wide (shared - // across every event), so one token controls all events. If `GRIDFPV_RD_TOKEN` is set to - // a non-blank value it is registered verbatim — a *known* credential so an automated - // client (the Playwright e2e, the Tauri app under test) can log in deterministically; - // otherwise a fresh random token is minted. + // Control auth is **full-trust (open) by default** (issue #72, Slice 1b): the control + // path requires no credential unless one is *configured*. So we only register an RD token + // when `GRIDFPV_RD_TOKEN` is set to a non-blank value — a *known* credential so an + // automated/remote client (the Tauri app, a token-gated deployment) can log in + // deterministically; with the env unset the Director registers **no** token and control is + // open (safe on loopback / a trusted LAN). The proper loopback-trust + remote-passphrase + // split for production is tracked separately as #80. let tokens = registry.tokens(); let rd_token = match std::env::var("GRIDFPV_RD_TOKEN") { - Ok(value) if tokens.register_rd_token(&value) => value, - _ => tokens.issue_rd_token(), + Ok(value) if tokens.register_rd_token(&value) => Some(value), + _ => None, }; // Resolve the built-in lap source (default `sim`) and spawn the **per-event** @@ -68,7 +69,7 @@ async fn serve() -> Result<(), Box> { let listener = tokio::net::TcpListener::bind(config.addr).await?; let bound = listener.local_addr()?; - print_startup(&config, bound, &rd_token, &source_desc); + print_startup(&config, bound, rd_token.as_deref(), &source_desc); // Serve until Ctrl-C; the protocol API + the RD console SPA are live on `bound`. axum::serve(listener, app) @@ -80,8 +81,15 @@ async fn serve() -> Result<(), Box> { } /// 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. -fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &str, source_desc: &str) { +/// 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()) @@ -92,8 +100,19 @@ fn print_startup(config: &Config, bound: std::net::SocketAddr, rd_token: &str, s println!("GridFPV Director {} — serving", env!("CARGO_PKG_VERSION")); println!(" listening on : http://{bound}"); println!(" console URL : http://{url_host}/"); - println!(" RD token : {rd_token}"); - println!(" (use as `Authorization: Bearer {rd_token}` on the control path)"); + 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 {}", diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 5ce879f..20f66c2 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -336,20 +336,22 @@ async fn list_events(State(registry): State) -> Json, Json(body): Json, ) -> Result, ProtocolError> { let meta = registry - .create(&body.name) + .create(&body) .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?; Ok(Json(meta)) } @@ -1204,10 +1206,13 @@ mod tests { } #[tokio::test] - async fn minting_a_join_token_requires_an_rd_token() { + 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. + // No token → 401 (control is now gated). let (status, _) = post_join_token(registry.clone(), None).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -1220,4 +1225,14 @@ mod tests { 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())); + } } diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index a764528..4981a74 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -189,31 +189,71 @@ impl TokenStore { .cloned() } - /// Authenticate a control caller: a token resolving to a **control-authorized** - /// session ([`Role::can_control`]) succeeds; anything else — `None` token, an unknown - /// or revoked token, or a read-only/join token — is [`ErrorCode::Unauthorized`]. + /// 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**: an anonymous (no-token) caller is admitted + /// ([`Role::Rd`]). This is the local-trust posture for an unconfigured Director. + /// - **A token is present** ⇒ it is always validated, even on an otherwise-open Director: + /// a present-but-unknown/revoked token, or a read-only/join token, is rejected. (So a + /// stale token never silently succeeds.) + /// - **A control credential IS configured** and **no token** is presented ⇒ rejected. + /// This preserves the original gated behaviour the moment a token is configured. /// /// This is the whole control policy in one place; [`ControlAuth`] is the only caller - /// (see [`crate::control_handler`]). + /// (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 { - let token = token.ok_or_else(|| { - ProtocolError::new( - ErrorCode::Unauthorized, - "control requires an Authorization: Bearer header", - ) - })?; - match self.session(token) { - Some(session) if session.role.can_control() => Ok(session), - Some(_) => Err(ProtocolError::new( - ErrorCode::Unauthorized, - "this token is read-only and may not drive control", - )), - None => Err(ProtocolError::new( - ErrorCode::Unauthorized, - "unknown or revoked control token", - )), + match token { + // A present token is always validated, on an open or gated Director alike. + 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", + )), + }, + // No token: open when nothing is configured (full trust), else gated. + None => { + if self.has_control_credential() { + Err(ProtocolError::new( + ErrorCode::Unauthorized, + "control requires an Authorization: Bearer header", + )) + } else { + // Full-trust: no credential configured, so an anonymous caller controls. + Ok(Session { role: Role::Rd }) + } + } } } @@ -337,13 +377,55 @@ mod tests { } #[test] - fn no_token_is_rejected_on_control_but_allowed_on_read() { + 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_read(None).unwrap().is_none()); + 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_but_invalid_token_is_rejected_even_on_an_open_director() { + // Even with nothing configured (open), a *presented* token must be valid — a stale + // token never silently succeeds. + let store = TokenStore::new(); + assert_eq!( + store.authenticate_control(Some("stale")).unwrap_err().code, + ErrorCode::Unauthorized + ); + // A read-only token presented on control is still rejected, open or not. + let join = store.issue_join_token(); + assert_eq!( + store.authenticate_control(Some(&join)).unwrap_err().code, + ErrorCode::Unauthorized + ); } #[test] diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 5fcafa5..2ab549e 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -24,6 +24,13 @@ * the shell) lets {@link send}/{@link createEventAndEnter} prompt for the token only when * a privileged action actually needs one. * + * **Full-trust by default (#72, Slice 1b):** privileged actions are sent **without** a token + * first. Only if the Director responds **401/403** (i.e. it has a token configured and is + * gating control) does the lazy {@link TokenDialog} open; the entered token is then reused for + * the rest of the session and the action is retried. Against an open (unconfigured) Director — + * the local-trust posture — there is therefore **no prompt ever**. (The proper loopback-trust + + * remote-passphrase split is tracked separately as #80 and is not built here.) + * * Everything reactive is Svelte 5 runes so screens read `session.connectionStatus`, * `session.liveState`, etc. directly. The protocol client is framework-agnostic, so we * bridge its `onState` callback into a `$state` field here. @@ -36,6 +43,7 @@ import type { ControlClient } from './control.js'; import type { Command, CommandAck, + CreateEventRequest, EventMeta, HeatId, HeatResult, @@ -46,6 +54,30 @@ import type { const TOKEN_STORAGE_KEY = 'gridfpv.rd.token'; +/** The optional descriptive fields a create-event dialog may supply alongside the name. */ +export type CreateEventFields = Omit; + +/** + * Whether a failed {@link CommandAck} is an **auth** rejection (the Director is gating + * control). These are the only acks that warrant the lazy token prompt; everything else + * (a bad transition, a transport error) is surfaced as-is. + */ +function isAuthAck(ack: CommandAck): boolean { + // The Director rejects an unauthenticated control caller with `Unauthorized` (HTTP 401); + // that is the one ack that means "control is gated — a token is needed". + return ack.error?.code === 'Unauthorized'; +} + +/** + * Whether a thrown error from `createEvent` is an HTTP **401/403** — i.e. the Director is + * gating event creation. `createEvent` rejects with an `Error` whose message carries the HTTP + * status (`POST /events failed: HTTP 401`), so we match on that. + */ +function isAuthFailure(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e); + return /\b(401|403)\b/.test(msg); +} + /** * Asks the RD for a control token. Returns the entered token, or `undefined` if the * RD cancelled. The shell wires this to a token `Dialog`; the session calls it lazily @@ -250,12 +282,11 @@ export class Session { } /** - * Ensure a token is held, prompting for one lazily if not. Returns `true` once a token - * is present, `false` if the RD cancelled the prompt. The control client is rebuilt to - * carry a freshly-obtained token. + * Prompt for a token via the lazy provider and hold it. Returns `true` once a token was + * entered, `false` if there is no provider or the RD cancelled. The control client is + * rebuilt (via {@link setToken}) to carry the freshly-obtained token. */ - async #ensureToken(): Promise { - if (this.#token) return true; + async #promptForToken(): Promise { if (!this.#tokenProvider) return false; const entered = await this.#tokenProvider(); if (!entered?.trim()) return false; @@ -264,22 +295,41 @@ export class Session { } /** - * Create a persistent event by name (`POST /events`, RD-gated) and enter it. Obtains - * the RD token lazily if none is held. Returns the new {@link EventMeta}, or throws on - * a transport/HTTP failure, or `undefined` if the RD cancelled the token prompt. + * Create a persistent event by name (`POST /events`) and enter it. + * + * Full-trust first (#72, Slice 1b): the create is attempted **without** a token; only if the + * Director rejects it for **auth** (it has a token configured) does the lazy prompt fire, and + * the create is retried once with the entered token. Returns the new {@link EventMeta}, throws + * on a non-auth transport/HTTP failure, or resolves `undefined` if the RD cancelled the prompt. */ - async createEventAndEnter(name: string): Promise { - if (!(await this.#ensureToken()) || !this.#token) return undefined; - const meta = await this.#createEventImpl(this.baseUrl, name, this.#token); - this.selectEvent(meta); - return meta; + async createEventAndEnter( + name: string, + fields?: CreateEventFields + ): Promise { + try { + const meta = await this.#createEventImpl(this.baseUrl, name, this.#token, { fields }); + this.selectEvent(meta); + return meta; + } catch (e) { + // A held token that still failed for auth, or a non-auth failure, is a real error. + if (this.#token || !isAuthFailure(e)) throw e; + // Open Director would have succeeded; a 401/403 means control is gated — prompt once. + if (!(await this.#promptForToken())) return undefined; + const meta = await this.#createEventImpl(this.baseUrl, name, this.#token, { fields }); + this.selectEvent(meta); + return meta; + } } /** * Send a privileged command through the control client, recording any error for the UI. - * Obtains the RD token lazily first (issue #72): a control action is the moment auth is - * actually needed, so if no token is held the shell's token prompt fires; cancelling - * yields an `Unauthorized` ack. Returns the raw `CommandAck` so callers branch on success. + * + * Full-trust first (issue #72, Slice 1b): the command is sent **without** prompting. Against + * an open (unconfigured) Director it just succeeds — no prompt ever. Only if the Director + * **rejects it for auth** (`Unauthorized`/`Forbidden` — it has a token configured) and no + * token is held yet does the lazy prompt fire; on a token, the command is **retried once** + * and the token reused for the rest of the session. Cancelling the prompt leaves the original + * `Unauthorized` ack. Returns the raw `CommandAck` so callers branch on success. */ async send(command: Command): Promise { if (!this.#control) { @@ -290,17 +340,13 @@ export class Session { this.lastCommandError = ack.error; return ack; } - // Lazy auth: only reach for the token (and its async prompt) when none is held — a - // held token sends immediately, keeping the common path a single await. - if (!this.#token && !(await this.#ensureToken())) { - const ack: CommandAck = { - ok: false, - error: { code: 'Unauthorized', message: 'A control token is required for this action.' } - }; - this.lastCommandError = ack.error; - return ack; + let ack = await this.#control.sendCommand(command); + // Only an *auth* rejection with no token yet triggers the lazy prompt + one retry; any + // other failure (a bad transition, a transport error) is surfaced as-is. + if (!ack.ok && !this.#token && isAuthAck(ack) && (await this.#promptForToken())) { + // setToken rebuilt #control with the token; resend on the new client. + ack = (await this.#control?.sendCommand(command)) ?? ack; } - const ack = await this.#control.sendCommand(command); this.lastCommandError = ack.ok ? undefined : ack.error; return ack; } diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 39eb683..d372826 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -75,7 +75,9 @@ describe('Session', () => { }); }); - it('send prompts for the token lazily the first time, then reuses it', async () => { + it('send goes through with NO prompt against an open Director (full-trust)', async () => { + // An open (unconfigured) Director accepts the command tokenless, so the lazy prompt + // must never fire — this is the no-prompt-ever path (#72, Slice 1b). const { connect } = mockConnect(connecting); const sendCommand = vi.fn(async (): Promise => okAck); const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); @@ -84,22 +86,53 @@ describe('Session', () => { const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); session.setTokenProvider(tokenProvider); session.selectEvent(PRACTICE); + + const ack = await session.send({ Stage: { heat: 'heat-1' } }); + expect(ack.ok).toBe(true); + expect(tokenProvider).not.toHaveBeenCalled(); expect(session.hasToken).toBe(false); + expect(sendCommand).toHaveBeenCalledWith({ Stage: { heat: 'heat-1' } }); + }); + + it('send prompts on a 401 ack, then retries with the entered token and reuses it', async () => { + // A token-gated Director answers Unauthorized; the lazy prompt fires once, the command + // is retried with the entered token, and the token is reused for the next send. + const { connect } = mockConnect(connecting); + const unauthorized: CommandAck = { + ok: false, + error: { code: 'Unauthorized', message: 'gated' } + }; + let calls = 0; + const sendCommand = vi.fn(async (): Promise => { + calls += 1; + // Reject until a token is held (the factory is re-invoked with the token on retry). + return calls === 1 ? unauthorized : okAck; + }); + const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + + const session = new Session({ connectImpl: connect, controlFactory, autoRestore: false }); + session.setTokenProvider(tokenProvider); + session.selectEvent(PRACTICE); const ack = await session.send({ Stage: { heat: 'heat-1' } }); expect(ack.ok).toBe(true); expect(tokenProvider).toHaveBeenCalledOnce(); expect(session.hasToken).toBe(true); - expect(sendCommand).toHaveBeenCalledWith({ Stage: { heat: 'heat-1' } }); + expect(sendCommand).toHaveBeenCalledTimes(2); // initial reject + retry - // Second send reuses the held token — no second prompt. + // A subsequent send reuses the held token — no second prompt. await session.send({ Arm: { heat: 'heat-1' } }); expect(tokenProvider).toHaveBeenCalledOnce(); }); - it('send returns Unauthorized when the RD cancels the token prompt', async () => { + it('send surfaces the Unauthorized ack when the RD cancels the token prompt', async () => { const { connect } = mockConnect(connecting); - const sendCommand = vi.fn(async (): Promise => okAck); + const unauthorized: CommandAck = { + ok: false, + error: { code: 'Unauthorized', message: 'gated' } + }; + const sendCommand = vi.fn(async (): Promise => unauthorized); const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); const tokenProvider = vi.fn(async () => undefined); // cancelled @@ -110,7 +143,8 @@ describe('Session', () => { const ack = await session.send({ Stage: { heat: 'heat-1' } }); expect(ack.ok).toBe(false); expect(ack.error?.code).toBe('Unauthorized'); - expect(sendCommand).not.toHaveBeenCalled(); + expect(tokenProvider).toHaveBeenCalledOnce(); + expect(session.hasToken).toBe(false); }); it('send routes a Command and records control errors when a token is held', async () => { @@ -155,11 +189,71 @@ describe('Session', () => { session.setToken('tok'); const meta = await session.createEventAndEnter('Friday Series'); - expect(createEventImpl).toHaveBeenCalledWith(session.baseUrl, 'Friday Series', 'tok'); + expect(createEventImpl).toHaveBeenCalledWith(session.baseUrl, 'Friday Series', 'tok', { + fields: undefined + }); expect(meta?.id).toBe('evt-a'); expect(session.currentEvent?.id).toBe('evt-a'); }); + it('createEventAndEnter creates tokenless against an open Director (no prompt)', async () => { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const createEventImpl = vi.fn(async () => EVENT_A); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + + const session = new Session({ + connectImpl: connect, + controlFactory, + createEventImpl, + autoRestore: false + }); + session.setTokenProvider(tokenProvider); + + const meta = await session.createEventAndEnter('Friday Series', { + date: '2026-06-20', + location: 'Main field' + }); + // Sent with no token and the optional fields forwarded; no prompt against an open Director. + expect(createEventImpl).toHaveBeenCalledWith(session.baseUrl, 'Friday Series', undefined, { + fields: { date: '2026-06-20', location: 'Main field' } + }); + expect(tokenProvider).not.toHaveBeenCalled(); + expect(meta?.id).toBe('evt-a'); + }); + + it('createEventAndEnter prompts + retries when the Director gates create with a 401', async () => { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + let calls = 0; + const createEventImpl = vi.fn(async () => { + calls += 1; + if (calls === 1) throw new Error('POST /events failed: HTTP 401'); + return EVENT_A; + }); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + + const session = new Session({ + connectImpl: connect, + controlFactory, + createEventImpl, + autoRestore: false + }); + session.setTokenProvider(tokenProvider); + + const meta = await session.createEventAndEnter('Friday Series'); + expect(tokenProvider).toHaveBeenCalledOnce(); + expect(createEventImpl).toHaveBeenCalledTimes(2); + expect(session.hasToken).toBe(true); + expect(meta?.id).toBe('evt-a'); + }); + it('leaveEvent tears the read client down and returns to the picker', () => { const { connect, client } = mockConnect({ body: undefined, diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index 8a0f428..a4c3676 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -428,4 +428,37 @@ describe('events lifecycle helpers (#72)', () => { expect(meta.id).toBe('race-night-xy99'); expect(meta.persistent).toBe(true); }); + + it('createEvent omits Authorization when no token is given (full-trust open Director) and forwards optional fields', async () => { + const seen: { url: string; init?: RequestInit }[] = []; + const fetch: FetchLike = async (input, init) => { + seen.push({ url: String(input), init }); + return { + ok: true, + status: 200, + json: async (): Promise => ({ + id: 'spring-cup-ab12', + name: 'Spring Cup', + created_at: 3, + persistent: true, + date: '2026-06-20', + location: 'Main field' + }) + } as unknown as Response; + }; + const { createEvent } = await import('./client.js'); + const meta = await createEvent('http://director.local:8080', 'Spring Cup', undefined, { + fetch, + fields: { date: '2026-06-20', location: 'Main field' } + }); + const headers = seen[0].init?.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(JSON.parse(seen[0].init?.body as string)).toEqual({ + name: 'Spring Cup', + date: '2026-06-20', + location: 'Main field' + }); + expect(meta.date).toBe('2026-06-20'); + expect(meta.location).toBe('Main field'); + }); }); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 7fe8c00..549b2de 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -211,25 +211,30 @@ export async function listEvents( } /** - * Create a new event (`POST /events`) — issue #72. RD-gated: a valid RD `token` is required - * (the id is auto-generated server-side; the body carries only the display `name`). Resolves - * to the new event's {@link EventMeta}, or rejects on a non-2xx / transport failure. + * Create a new event (`POST /events`) — issue #72. Control is **full-trust by default** + * (#72, Slice 1b): the `token` is **optional** — an open (unconfigured) Director accepts the + * create with no credential; a token-gated Director answers **401/403** and the caller obtains + * a token lazily and retries. The body carries the display `name` plus any optional descriptive + * `fields` (`date`/`location`/`description`/`organizer`); the id is auto-generated server-side. + * Resolves to the new event's {@link EventMeta}, or rejects on a non-2xx / transport failure + * (the HTTP status is in the error message so the caller can branch on 401/403). */ export async function createEvent( baseUrl: string, name: string, - token: string, - options: { fetch?: FetchLike } = {} + token?: string, + options: { fetch?: FetchLike; fields?: Omit } = {} ): Promise { const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); - const body: CreateEventRequest = { name }; + const body: CreateEventRequest = { name, ...(options.fields ?? {}) }; + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; const resp = await fetchImpl(`${trimSlash(baseUrl)}/events`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${token}` - }, + headers, body: JSON.stringify(body) }); if (!resp.ok) throw new Error(`POST /events failed: HTTP ${resp.status}`); From 957a83e00efd19583fa25478076d1fdfcad7a5b1 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:02:52 +0000 Subject: [PATCH 050/362] Optional event fields: date/location/description/organizer (#72, C) Additive optional fields on EventMeta + CreateEventRequest, each #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] so a name-only create stays a one-field body and the wire omits unset fields. `date` is a free-form display string (a human label the RD types; created_at stays the authoritative machine timestamp). EventRegistry::create now takes the full CreateEventRequest and stores the (blank-normalized) fields on the new event's meta. Regenerated bindings/ (gen-drift clean). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/CreateEventRequest.ts | 19 ++++++- bindings/EventMeta.ts | 21 +++++++- crates/server/src/events.rs | 95 +++++++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts index 79b17b3..2826aea 100644 --- a/bindings/CreateEventRequest.ts +++ b/bindings/CreateEventRequest.ts @@ -12,4 +12,21 @@ export type CreateEventRequest = { /** * The display name for the new event. */ -name: string, }; +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/EventMeta.ts b/bindings/EventMeta.ts index cae6e87..6ba721b 100644 --- a/bindings/EventMeta.ts +++ b/bindings/EventMeta.ts @@ -28,4 +28,23 @@ created_at: number, * Whether the event's log is durable (a SQLite file) or ephemeral (the in-memory * Practice log, `false`). */ -persistent: boolean, }; +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, }; diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 786cd6f..8fb497c 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -72,6 +72,25 @@ pub struct EventMeta { /// 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 body of `POST /events` — the only thing a caller supplies when creating an event. @@ -85,6 +104,23 @@ pub struct EventMeta { 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, @@ -144,6 +180,10 @@ impl EventRegistry { name: PRACTICE_EVENT_NAME.to_string(), created_at: now_millis(), persistent: false, + date: None, + location: None, + description: None, + organizer: None, }, state: practice_state, }, @@ -198,15 +238,17 @@ impl EventRegistry { out } - /// Create a new persistent event from a display `name`, returning its [`EventMeta`]. + /// Create a new persistent event from a [`CreateEventRequest`], returning its [`EventMeta`]. /// - /// The **id is auto-generated** — a slug of `name` plus a short random suffix — so it is - /// unique and never user-entered (names are display-only). 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, name: &str) -> Result { + /// 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 @@ -234,11 +276,17 @@ impl EventRegistry { ), }; + // 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), }; reg.events.insert( id, @@ -284,6 +332,16 @@ fn now_millis() -> 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. @@ -325,6 +383,17 @@ mod tests { use super::*; use gridfpv_events::{CompetitorRef, Event, HeatId}; + /// A name-only create request (the common one-click path) for the tests. + 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(); @@ -345,8 +414,8 @@ mod tests { #[test] fn create_auto_generates_a_unique_slug_id() { let reg = EventRegistry::new(None).unwrap(); - let a = reg.create("Spring Cup 2026!").unwrap(); - let b = reg.create("Spring Cup 2026!").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-")); @@ -362,7 +431,7 @@ mod tests { 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("Race Night").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. @@ -387,7 +456,7 @@ mod tests { fn one_rd_token_controls_every_event() { let reg = EventRegistry::new(None).unwrap(); let rd = reg.tokens().issue_rd_token(); - let created = reg.create("Race Night").unwrap(); + 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(); @@ -412,7 +481,7 @@ mod tests { 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("Persisted").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"); From c1446de69b407075270f43879adc2a8c001bd003 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:03:03 +0000 Subject: [PATCH 051/362] UI feedback fixes + optional-detail create dialog (#72, A & C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1: Remove the redundant event field from setup — the console is already inside an event, so the wizard shows the current event read-only and no longer collects an event id/name (dropped from EventConfig + validation). A2: Topbar event name now navigates to live control (the workspace home), not the picker; only the sidebar "Switch event" returns to the picker. A3: Remove the persistent/ephemeral badge from picker rows. A4: Picker help text becomes a hover tooltip (Practice row `title`) / removed, keeping the picker clean. C: New-event dialog keeps name-only one-click as the default and puts date/location/organizer/description behind a collapsible "Add details (optional)" section; picker rows subtly show date/location where present. (The full context header is #85.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 35 +++--- frontend/apps/rd-console/src/lib/setup.ts | 26 ++-- .../rd-console/src/screens/EventPicker.svelte | 115 ++++++++++++++++-- .../rd-console/src/screens/SetupWizard.svelte | 32 +++-- .../apps/rd-console/tests/SetupWizard.test.ts | 11 +- frontend/apps/rd-console/tests/setup.test.ts | 8 +- 6 files changed, 164 insertions(+), 63 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index ad6f739..84220ea 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -52,11 +52,10 @@ // The setup wizard's config lives at the shell so it survives screen switches. let config = $state(emptyConfig()); - function onSetupCommit(committed: EventConfig) { - // Re-scope the live read client to the configured event once it's known. - if (committed.eventId.trim()) { - session.resubscribe({ Event: { event: committed.eventId.trim() } }); - } + function onSetupCommit() { + // The console is already inside an event (#72) — the live read client was scoped to it on + // entry — so committing the wizard just advances to registration; there is no separate + // event to re-scope to (the redundant event field was removed, #72 Slice 1b A1). active = 'registration'; } @@ -169,9 +168,13 @@
    -

    {activeScreen?.label}

    @@ -204,7 +207,11 @@
    {#if active === 'setup'} - + {:else if active === 'registration'} {:else if active === 'live'} @@ -449,10 +456,9 @@ gap: var(--gf-space-3); min-width: 0; } - .event-switch { + .event-home { display: inline-flex; align-items: center; - gap: var(--gf-space-1); max-width: 16rem; padding: var(--gf-space-1) var(--gf-space-2); margin-left: calc(-1 * var(--gf-space-2)); @@ -468,7 +474,7 @@ background var(--gf-motion-fast) var(--gf-ease-out), color var(--gf-motion-fast) var(--gf-ease-out); } - .event-switch:hover { + .event-home:hover { background: var(--gf-elevated); border-color: var(--gf-border); color: var(--gf-text); @@ -479,11 +485,6 @@ white-space: nowrap; font-weight: var(--gf-font-weight-semibold); } - .event-caret { - color: var(--gf-text-faint); - font-size: var(--gf-font-size-sm); - flex-shrink: 0; - } .sep { color: var(--gf-text-faint); } diff --git a/frontend/apps/rd-console/src/lib/setup.ts b/frontend/apps/rd-console/src/lib/setup.ts index 0b582e9..b8c245d 100644 --- a/frontend/apps/rd-console/src/lib/setup.ts +++ b/frontend/apps/rd-console/src/lib/setup.ts @@ -18,14 +18,7 @@ * though the event/class/track steps are local-only. */ -import type { - ClassId, - Command, - CompetitorRef, - EventId, - HeatId, - WinCondition -} from '@gridfpv/types'; +import type { ClassId, Command, CompetitorRef, HeatId, WinCondition } from '@gridfpv/types'; /** The race format a class runs under (roadmap v0.4: timed-qual / single-elim / zippyq). */ export type RaceFormat = 'timed-qual' | 'single-elim' | 'zippyq'; @@ -49,12 +42,15 @@ export interface ClassConfig { winCondition: WinCondition; } -/** The whole event configuration the wizard produces. */ +/** + * The whole event configuration the wizard produces. + * + * The event itself (id + name) is **no longer part of the wizard** (#72, Slice 1b A1): the + * console is always already inside an event, so the wizard configures that event's classes / + * track / format and never asks for the event again — the id/name are owned by the + * {@link EventMeta} the session holds, not collected here. + */ export interface EventConfig { - /** Event id (a stable handle the RD names). */ - eventId: EventId; - /** Human event name. */ - eventName: string; /** Track / venue name (free text until a track model exists on the wire). */ track: string; /** The classes configured for the event (at least one). */ @@ -78,7 +74,7 @@ export function defaultWinCondition(format: RaceFormat): WinCondition { /** An empty config to seed the wizard. */ export function emptyConfig(): EventConfig { - return { eventId: '', eventName: '', track: '', classes: [] }; + return { track: '', classes: [] }; } /** Build a default class config for `format`. */ @@ -92,8 +88,6 @@ export function defaultClass(id: ClassId, name: string, format: RaceFormat): Cla */ export function validateConfig(config: EventConfig): string[] { const problems: string[] = []; - if (!config.eventName.trim()) problems.push('Event needs a name.'); - if (!config.eventId.trim()) problems.push('Event needs an id.'); if (!config.track.trim()) problems.push('Track needs a name.'); if (config.classes.length === 0) problems.push('Add at least one class.'); config.classes.forEach((c, i) => { diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index 5c7af54..a33e926 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -12,9 +12,9 @@ * for the RD token (handled by the session's token provider). The Director address is the * page's own origin — there is no address to type. */ - import { Button, Card, Badge, Dialog, Field, Input, toast } from '@gridfpv/components'; + import { Button, Card, Dialog, Field, Input, toast } from '@gridfpv/components'; import type { EventMeta } from '@gridfpv/types'; - import type { Session } from '../lib/session.svelte.js'; + import type { Session, CreateEventFields } from '../lib/session.svelte.js'; import { PRACTICE_EVENT_ID } from '../lib/session.svelte.js'; let { session }: { session: Session } = $props(); @@ -26,9 +26,15 @@ let loadState = $state({ kind: 'loading' }); - // The "new event" dialog. + // The "new event" dialog. Name-only is the one-click default (#72, Slice 1b C); the + // optional descriptive fields live behind a collapsible "Add details" section. let newOpen = $state(false); let newName = $state(''); + let newDate = $state(''); + let newLocation = $state(''); + let newDescription = $state(''); + let newOrganizer = $state(''); + let detailsOpen = $state(false); let creating = $state(false); let newError = $state(undefined); @@ -70,16 +76,43 @@ } } + /** + * The subtle row subtitle: show the RD-supplied **date** and **location** where present + * (#72, Slice 1b C), falling back to the created date. The full context header is #85. + */ + function rowSubtitle(ev: EventMeta): string { + const parts: string[] = []; + if (ev.date?.trim()) parts.push(ev.date.trim()); + if (ev.location?.trim()) parts.push(ev.location.trim()); + if (parts.length > 0) return parts.join(' · '); + return `Created ${formatDate(ev.created_at)}`; + } + function enter(meta: EventMeta) { session.selectEvent(meta); } function openNew() { newName = ''; + newDate = ''; + newLocation = ''; + newDescription = ''; + newOrganizer = ''; + detailsOpen = false; newError = undefined; newOpen = true; } + /** Collect the optional detail fields, omitting blanks (server normalizes too). */ + function detailFields(): CreateEventFields | undefined { + const fields: CreateEventFields = {}; + if (newDate.trim()) fields.date = newDate.trim(); + if (newLocation.trim()) fields.location = newLocation.trim(); + if (newDescription.trim()) fields.description = newDescription.trim(); + if (newOrganizer.trim()) fields.organizer = newOrganizer.trim(); + return Object.keys(fields).length > 0 ? fields : undefined; + } + async function submitNew(e: Event) { e.preventDefault(); const name = newName.trim(); @@ -87,7 +120,7 @@ creating = true; newError = undefined; try { - const meta = await session.createEventAndEnter(name); + const meta = await session.createEventAndEnter(name, detailFields()); if (!meta) { // The RD cancelled the lazy token prompt — keep the dialog open, hint why. newError = 'A control token is required to create an event.'; @@ -150,13 +183,16 @@ {:else} {#if practice}
    -
    @@ -178,11 +214,8 @@ {ev.name} - Created {formatDate(ev.created_at)} + {rowSubtitle(ev)} - - {ev.persistent ? 'Persistent' : 'Ephemeral'} - @@ -207,6 +240,45 @@

    A persistent event keeps its heats, registration, and results. The id is generated for you.

    + + +
    + Add details (optional) +
    + + + + + + + + + + + + +
    +
    {#snippet footer()} @@ -452,4 +524,25 @@ font-size: var(--gf-font-size-xs); color: var(--gf-text-muted); } + .details { + border-top: 1px solid var(--gf-border-subtle); + padding-top: var(--gf-space-3); + } + .details > summary { + cursor: pointer; + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-medium); + color: var(--gf-text-secondary); + list-style: revert; + user-select: none; + } + .details > summary:hover { + color: var(--gf-text); + } + .details-grid { + display: flex; + flex-direction: column; + gap: var(--gf-space-3); + margin-top: var(--gf-space-3); + } diff --git a/frontend/apps/rd-console/src/screens/SetupWizard.svelte b/frontend/apps/rd-console/src/screens/SetupWizard.svelte index b3bba62..fcd1245 100644 --- a/frontend/apps/rd-console/src/screens/SetupWizard.svelte +++ b/frontend/apps/rd-console/src/screens/SetupWizard.svelte @@ -21,8 +21,13 @@ let { config = $bindable(), + eventName = '', oncommit = undefined - }: { config: EventConfig; oncommit?: (config: EventConfig) => void } = $props(); + }: { + config: EventConfig; + eventName?: string; + oncommit?: (config: EventConfig) => void; + } = $props(); const steps = ['Event', 'Classes', 'Track', 'Format', 'Review'] as const; let step = $state(0); @@ -96,10 +101,12 @@
    {#if step === 0}

    Event

    - - + +

    + Configuring {eventName || 'this event'}. +

    +

    Set up the classes, track, and format for this event below.

    {:else if step === 1}

    Classes

    {#if config.classes.length === 0} @@ -167,7 +174,7 @@

    Review

    Event
    -
    {config.eventName} ({config.eventId})
    +
    {eventName || 'this event'}
    Track
    {config.track}
    Classes
    @@ -319,14 +326,19 @@ margin: var(--gf-space-1) 0 var(--gf-space-3); font-size: var(--gf-font-size-sm); } - .review code { - color: var(--gf-text-muted); - font-family: var(--gf-font-mono); - } .muted { color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); } + .current-event { + margin: 0; + font-size: var(--gf-font-size-sm); + color: var(--gf-text-secondary); + } + .current-event strong { + color: var(--gf-text); + font-weight: var(--gf-font-weight-semibold); + } .problems { color: var(--gf-danger); font-size: var(--gf-font-size-sm); diff --git a/frontend/apps/rd-console/tests/SetupWizard.test.ts b/frontend/apps/rd-console/tests/SetupWizard.test.ts index defa247..d3d0764 100644 --- a/frontend/apps/rd-console/tests/SetupWizard.test.ts +++ b/frontend/apps/rd-console/tests/SetupWizard.test.ts @@ -7,20 +7,21 @@ import { emptyConfig, type EventConfig } from '../src/lib/setup.js'; describe('SetupWizard', () => { it('walks the steps and commits a complete config', async () => { const config: EventConfig = { - eventId: 'spring', - eventName: 'Spring Cup', track: 'Main field', classes: [{ id: 'open', name: 'Open', format: 'timed-qual', winCondition: 'BestLap' }] }; const oncommit = vi.fn(); - render(SetupWizard, { config, oncommit }); + render(SetupWizard, { config, eventName: 'Spring Cup', oncommit }); + + // Step 0 shows the current event read-only — it never asks for the event again (#72 A1). + expect(screen.getByText('Spring Cup')).toBeInTheDocument(); // Jump to the Review step. await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); await fireEvent.click(screen.getByRole('button', { name: 'Save configuration' })); expect(oncommit).toHaveBeenCalledOnce(); - expect(oncommit.mock.calls[0][0]).toMatchObject({ eventId: 'spring', eventName: 'Spring Cup' }); + expect(oncommit.mock.calls[0][0]).toMatchObject({ track: 'Main field' }); }); it('blocks finishing an incomplete config and lists the problems', async () => { @@ -30,6 +31,6 @@ describe('SetupWizard', () => { await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); const save = screen.getByRole('button', { name: 'Save configuration' }) as HTMLButtonElement; expect(save.disabled).toBe(true); - expect(screen.getByText('Event needs a name.')).toBeInTheDocument(); + expect(screen.getByText('Track needs a name.')).toBeInTheDocument(); }); }); diff --git a/frontend/apps/rd-console/tests/setup.test.ts b/frontend/apps/rd-console/tests/setup.test.ts index 6573808..4532bc0 100644 --- a/frontend/apps/rd-console/tests/setup.test.ts +++ b/frontend/apps/rd-console/tests/setup.test.ts @@ -23,9 +23,9 @@ describe('setup config', () => { }); it('validates a complete config as ready', () => { + // The event id/name is no longer part of the wizard config (#72, Slice 1b A1) — the + // console is already inside an event; the wizard only needs a track + at least one class. const c: EventConfig = { - eventId: 'spring', - eventName: 'Spring Cup', track: 'Main field', classes: [defaultClass('open', 'Open', 'timed-qual')] }; @@ -34,8 +34,8 @@ describe('setup config', () => { }); it('flags each missing piece', () => { - const problems = validateConfig({ eventId: '', eventName: '', track: '', classes: [] }); - expect(problems).toContain('Event needs a name.'); + const problems = validateConfig({ track: '', classes: [] }); + expect(problems).toContain('Track needs a name.'); expect(problems).toContain('Add at least one class.'); }); From 86e628b73c9a6f56be7883de0af0f982ed211c0f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:03:18 +0000 Subject: [PATCH 052/362] e2e: drive the open Director with no token step (#72, Keep green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default e2e Director now boots with NO token configured (full-trust), so the run goes picker → Practice → build heat → control with no token step — the lazy prompt never fires (asserted hidden). The boot harness gains `token: false` to leave GRIDFPV_RD_TOKEN unset; observability.ts uses it. The token-gated path stays covered by the contract control suite (which boots with a token). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/e2e/observability.ts | 6 +++++- frontend/e2e/race.spec.ts | 25 ++++++++++++------------- frontend/test-harness/director.ts | 19 +++++++++++++++---- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/frontend/e2e/observability.ts b/frontend/e2e/observability.ts index 63ae37a..78ed5b6 100644 --- a/frontend/e2e/observability.ts +++ b/frontend/e2e/observability.ts @@ -82,8 +82,12 @@ export const test = base.extend({ // this fixture has none, so the pattern is empty. // eslint-disable-next-line no-empty-pattern async ({}, use) => { + // Boot with NO token configured (`token: false`): control is open (full-trust by + // default, #72 Slice 1b), so the console drives the whole race with no token step — + // the lazy prompt never fires. (A token-gated path can be covered by a focused spec + // that passes an explicit `token`.) const director = await startDirector({ - token: 'test-rd-token', + token: false, assets: dist, simLaps: SIM_LAPS, simLapMs: SIM_LAP_MS diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 08c1ee6..4da5da2 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -3,11 +3,14 @@ * observability harness; entry reshaped for the event-picker landing, #72 Slice 1b). * * This is the deliverable proof: a person opens the RD console, lands on the **event - * picker**, enters **Practice**, defines a heat with named pilots, runs it (providing the - * RD token when the lazy prompt fires on the first control action), **watches the live laps - * climb in the rendered DOM**, finishes + scores, and reads results with the pilots and - * their lap counts — every step a real click/input in headless chromium, every command on - * the real control path, every lap from the real built-in sim source. Nothing is mocked. + * picker**, enters **Practice**, defines a heat with named pilots, runs it, **watches the live + * laps climb in the rendered DOM**, finishes + scores, and reads results with the pilots and + * their lap counts — every step a real click/input in headless chromium, every command on the + * real control path, every lap from the real built-in sim source. Nothing is mocked. + * + * The Director is booted with **no token configured**, so control is **open** (full-trust by + * default, #72 Slice 1b): the run goes picker → Practice → build heat → control with **no token + * step** — the lazy prompt never fires. (The token-gated path is a separate, optional concern.) * * The lap-counts-climbing assertion is the load-bearing one: it polls the per-pilot lap * numbers rendered in the HeatSheet and asserts they increase over a couple of seconds — @@ -21,7 +24,6 @@ * console, page errors, WS frames, and the Director's server log — together. */ import { expect, test } from './observability.js'; -import { RD_TOKEN } from '../playwright.config.js'; const PILOTS = ['Ace', 'Bee', 'Cee']; const HEAT_ID = 'q-1'; @@ -56,14 +58,11 @@ test('RD drives a full basic sim race through the console UI', async ({ page }) await newHeat.getByLabel(`Pilot ${i + 1} name`).fill(PILOTS[i]); } - // ── Schedule it: this is the first control action, so the lazy RD-token prompt fires ── + // ── Schedule it: the Director is open (no token configured), so this control action goes + // straight through — NO token prompt appears (full-trust by default, #72) ────────────── await newHeat.getByRole('button', { name: 'Schedule heat' }).click(); - // Provide the known RD token in the lazy prompt; once entered, the action proceeds and - // the token is reused for every subsequent control action this session. - const tokenPrompt = page.getByRole('form', { name: 'Control token' }); - await expect(tokenPrompt).toBeVisible(); - await tokenPrompt.getByLabel('Control token').fill(RD_TOKEN); - await page.getByRole('button', { name: 'Use token' }).click(); + // The lazy token prompt must NOT appear against an open Director. + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); // The heat lands on the timer: current heat + the lineup show, phase Scheduled. await expect(page.locator('.heat-id .value')).toHaveText(HEAT_ID); diff --git a/frontend/test-harness/director.ts b/frontend/test-harness/director.ts index 8d91c48..44b7194 100644 --- a/frontend/test-harness/director.ts +++ b/frontend/test-harness/director.ts @@ -32,8 +32,15 @@ export const DIRECTOR_BIN = resolve(repoRoot, 'target', 'debug', 'gridfpv'); export interface StartDirectorOptions { /** Port to bind. Default: an ephemeral free port picked by the OS. */ port?: number; - /** The known RD control token to pin (`GRIDFPV_RD_TOKEN`). Default: a fresh per-call token. */ - token?: string; + /** + * The known RD control token to pin (`GRIDFPV_RD_TOKEN`). Default: a fresh per-call token. + * + * Pass **`false`** to start the Director with **no token configured** — `GRIDFPV_RD_TOKEN` + * is left unset, so the control path is **open** (full-trust by default, #72 Slice 1b). The + * returned handle's `token` is then the empty string. This is the posture the no-token e2e + * exercises (picker → Practice → build heat → control with no token step). + */ + token?: string | false; /** Built RD console `dist/` dir to serve as the SPA (`GRIDFPV_ASSETS`). Default: unset (API only). */ assets?: string; /** Number of sim laps per heat (`GRIDFPV_SIM_LAPS`). Default: `4`. */ @@ -93,6 +100,10 @@ export async function startDirector(opts: StartDirectorOptions = {}): Promise = token === false ? {} : { GRIDFPV_RD_TOKEN: token }; + const handleToken = token === false ? '' : token; // Build the binary on demand so a fresh checkout / CI just works. if (build && !existsSync(DIRECTOR_BIN)) { @@ -124,7 +135,7 @@ export async function startDirector(opts: StartDirectorOptions = {}): Promise Date: Sat, 20 Jun 2026 21:12:27 +0000 Subject: [PATCH 053/362] rd-console: event date field is a calendar picker (type=date) Feedback: the optional event Date should be a calendar picker, not free text. Input already forwards 'type' via ...rest, so it's a native date input; value is a YYYY-MM-DD string (date stays a free-form display string on the wire). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/screens/EventPicker.svelte | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index a33e926..fcdf1a3 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -246,12 +246,7 @@ Add details (optional)
    - + Date: Sat, 20 Jun 2026 21:14:53 +0000 Subject: [PATCH 054/362] Context header: event + live race state on every page (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a persistent context bar across every in-event page of the RD console so the RD always sees which event they're in and what's on the timer, regardless of which screen is open (live control, registration, marshaling, results, setup). The bar shows: • Event name (prominent) — clicking it goes to Live control, not the picker. • Current heat + a phase pill (Slice 0 StatusPill phase colors) + a live RaceClock while Running; "No heat on the timer" when the event is idle. • The connection status pill. • "← Switch event" — now the only way back to the picker. Per the maintainer spec, there is no date/location hover. The #62 race-clock logic (tick while Running, freeze on Finished/Scored, reset otherwise) is lifted into a shared useRaceClock helper so the header and the Live control screen drive the SAME clock from one place; LiveRaceControl now consumes the helper instead of its own duplicated $effect. The picker (no-event) state is unchanged — the header is workspace-only — and the recent polish is preserved (event-name → live control, switch-event → picker). The e2e DOM hooks stay green: the header uses distinct classes (.ctx-heat-id, .ctx-phase) so the canonical .heat-id .value / .phase selectors remain unique to LiveRaceControl, and .conn-label moves into the header as the single status-text hook. Gates: build, check, lint, test, contract, and the browser e2e (1 passed). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 121 +--------- .../apps/rd-console/src/ContextHeader.svelte | 222 ++++++++++++++++++ .../rd-console/src/lib/raceClock.svelte.ts | 64 +++++ .../src/screens/LiveRaceControl.svelte | 42 +--- 4 files changed, 304 insertions(+), 145 deletions(-) create mode 100644 frontend/apps/rd-console/src/ContextHeader.svelte create mode 100644 frontend/apps/rd-console/src/lib/raceClock.svelte.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 84220ea..f646f1c 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -15,8 +15,9 @@ * routes between them and owns the session. */ import '@gridfpv/components/tokens.css'; - import { StatusPill, ToastHost, Dialog, Button, Field, Input, toast } from '@gridfpv/components'; + import { ToastHost, Dialog, Button, Field, Input, toast } from '@gridfpv/components'; import { Session } from './lib/session.svelte.js'; + import ContextHeader from './ContextHeader.svelte'; import { emptyConfig, type EventConfig } from './lib/setup.js'; import EventPicker from './screens/EventPicker.svelte'; import TokenDialog from './screens/TokenDialog.svelte'; @@ -96,9 +97,6 @@ tokenInput = ''; toast.info('Control token cleared.'); } - - const eventName = $derived(session.currentEvent?.name ?? ''); - const liveHeat = $derived(session.liveState?.current_heat); @@ -156,34 +154,15 @@
    -
    - - -

    {activeScreen?.label}

    - {#if liveHeat} - Heat {liveHeat} - {/if} -
    + (active = 'live')} onswitchevent={leaveToPicker} />
    - +

    {activeScreen?.label}

    + + {#if heat} + +
    + Heat + {heat} +
    + {#if phase} + + {/if} + {#if running} + + {/if} + {:else} + + No heat on the timer + {/if} +
    + +
    +
    + + + {session.connectionStatus} +
    + +
    +
    + + diff --git a/frontend/apps/rd-console/src/lib/raceClock.svelte.ts b/frontend/apps/rd-console/src/lib/raceClock.svelte.ts new file mode 100644 index 0000000..db81182 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/raceClock.svelte.ts @@ -0,0 +1,64 @@ +/** + * The shared race clock (#62), lifted out of {@link LiveRaceControl} so the persistent + * {@link ContextHeader} and the live screen drive the **same** clock from one place (#85). + * + * Behaviour, keyed off the live `phase`: + * • Running → tick a wall-clock timer (`Date.now() - start`) every 50ms. + * • Finished / Scored → freeze the clock at its last ticked value (stop ticking). + * • Scheduled/Staged/Armed → reset to 0 (a fresh/idle heat, or no heat at all). + * The off-ramps Abort/Restart/Discard aren't phases of their own — they fold back onto one + * of the above (typically Scheduled), so they fall out of these same rules. + * + * This is the v1, *approximate* clock: on a late join (the heat is already Running when this + * mounts, or after a reconnect) it counts from "now", not the real heat start, so the time can + * read short. The fuller fix (#62 follow-up) is a server-authoritative start time in + * `LiveRaceState`. Sharing the logic here means that fix lands in exactly one place. + * + * Usage (inside a component, so the `$effect` has an owner): + * const clock = useRaceClock(() => session.liveState?.phase ?? 'Scheduled'); + * + */ + +/** A live, ticking elapsed-millis reading whose behaviour follows the heat `phase`. */ +export interface RaceClockState { + /** Milliseconds elapsed in the current Running window; frozen/zeroed per the rules above. */ + readonly elapsedMs: number; +} + +/** + * Create a phase-driven race clock. `getPhase` is read reactively, so the timer starts, + * freezes, and resets as the live phase changes. Must be called during component setup so + * its internal `$effect` is owned (and torn down on unmount). + */ +export function useRaceClock(getPhase: () => string | undefined): RaceClockState { + let elapsedMs = $state(0); + + $effect(() => { + // `phase` is the only reactive read in this effect, so it re-runs *only* on a phase + // change — not on every render — which keeps the clock from restarting spuriously. + const phase = getPhase(); + if (phase === 'Running') { + const startedAt = Date.now(); + const advance = () => { + elapsedMs = Date.now() - startedAt; + }; + advance(); + const id = setInterval(advance, 50); + // Teardown: stop ticking when the phase leaves Running (or on unmount). The next + // effect run applies the freeze (Finished/Scored) or reset (everything else). + return () => clearInterval(id); + } + if (phase === 'Finished' || phase === 'Scored') { + // Freeze: leave `elapsedMs` at its last Running value. + return; + } + // Scheduled / Staged / Armed / no heat (incl. where Abort/Restart/Discard land): reset. + elapsedMs = 0; + }); + + return { + get elapsedMs() { + return elapsedMs; + } + }; +} diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 6f1c244..5d74c03 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -21,6 +21,7 @@ type HeatAction } from '../lib/transitions.js'; import type { Session } from '../lib/session.svelte.js'; + import { useRaceClock } from '../lib/raceClock.svelte.js'; import ConfirmButton from '../lib/ConfirmButton.svelte'; import ErrorBanner from '../lib/ErrorBanner.svelte'; import NewHeat from './NewHeat.svelte'; @@ -33,42 +34,11 @@ const primary = $derived(primaryAction(phase)); // ── Race clock (#62) ──────────────────────────────────────────────────────────────── - // Drive the `RaceClock`'s `elapsedMs` client-side off the live `phase`. Behaviour: - // • Running → tick a wall-clock timer (`Date.now() - start`) every 50ms. - // • Finished / Scored → freeze the clock at its last ticked value (stop ticking). - // • Scheduled/Staged/Armed → reset to 0 (a fresh/idle heat, or no heat at all). - // The off-ramps Abort/Restart/Discard aren't phases of their own — they fold back onto - // one of the above (typically Scheduled), so they fall out of these same rules: an abort - // back to Scheduled resets, a restart back to Scheduled resets, etc. - // - // This is the v1 fix and is *approximate*: on a late join (the heat is already Running - // when this screen mounts, or after a reconnect) we start counting from "now", not from - // the real heat start, so the displayed time can be short. The fuller fix (#62 follow-up) - // is a server-authoritative start time in `LiveRaceState` — the recorded-at of the - // Running transition — which makes the clock exact and reconnect-safe. - let elapsedMs = $state(0); - - $effect(() => { - // `phase` is the only reactive read in this effect, so it re-runs *only* on a phase - // change — not on every render — which keeps the clock from restarting spuriously. - if (phase === 'Running') { - const startedAt = Date.now(); - const advance = () => { - elapsedMs = Date.now() - startedAt; - }; - advance(); - const id = setInterval(advance, 50); - // Teardown: stop ticking when the phase leaves Running (or on unmount). The next - // effect run applies the freeze (Finished/Scored) or reset (everything else). - return () => clearInterval(id); - } - if (phase === 'Finished' || phase === 'Scored') { - // Freeze: leave `elapsedMs` at its last Running value. - return; - } - // Scheduled / Staged / Armed / no heat (incl. where Abort/Restart/Discard land): reset. - elapsedMs = 0; - }); + // The phase-driven elapsed clock now lives in the shared `useRaceClock` helper so the + // persistent ContextHeader (#85) and this screen drive the *same* clock from one place + // (ticks while Running, freezes on Finished/Scored, resets otherwise). See raceClock.svelte.ts. + const clock = useRaceClock(() => phase); + const elapsedMs = $derived(clock.elapsedMs); // A live, provisional leaderboard from the running order + per-pilot progress, so the // RD sees standings before the heat is scored. Built into a `HeatResult` so we reuse From 47a056b43586bb750210660a158bddec49d70e1f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:21:07 +0000 Subject: [PATCH 055/362] fix(auth): an open Director ignores any presented token (full trust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale token (e.g. left in the browser from when the Director was run gated) made POST /events 401 on an OPEN Director, because authenticate_control validated a *present* token even with nothing configured. Reverse the order: when no control credential is configured, control is OPEN and admits as RD regardless of any token (stale/unknown/read-only are ignored — nothing to validate against). Token validation/rejection now applies only when a credential IS configured (gated). Updated the unit + integration tests that asserted the old 'open rejects bad tokens' behavior; revoke tests keep a second credential so control stays gated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/server/src/auth.rs | 71 ++++++++++++++++++---------------- crates/server/tests/control.rs | 5 ++- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 4981a74..e0e6191 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -216,21 +216,27 @@ impl TokenStore { /// /// Policy, in order: /// - **No control credential configured** ([`has_control_credential`](Self::has_control_credential) - /// is `false`) ⇒ control is **open**: an anonymous (no-token) caller is admitted - /// ([`Role::Rd`]). This is the local-trust posture for an unconfigured Director. - /// - **A token is present** ⇒ it is always validated, even on an otherwise-open Director: - /// a present-but-unknown/revoked token, or a read-only/join token, is rejected. (So a - /// stale token never silently succeeds.) - /// - **A control credential IS configured** and **no token** is presented ⇒ rejected. - /// This preserves the original gated behaviour the moment a token is configured. + /// 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 { - // A present token is always validated, on an open or gated Director alike. Some(token) => match self.session(token) { Some(session) if session.role.can_control() => Ok(session), Some(_) => Err(ProtocolError::new( @@ -242,18 +248,10 @@ impl TokenStore { "unknown or revoked control token", )), }, - // No token: open when nothing is configured (full trust), else gated. - None => { - if self.has_control_credential() { - Err(ProtocolError::new( - ErrorCode::Unauthorized, - "control requires an Authorization: Bearer header", - )) - } else { - // Full-trust: no credential configured, so an anonymous caller controls. - Ok(Session { role: Role::Rd }) - } - } + None => Err(ProtocolError::new( + ErrorCode::Unauthorized, + "control requires an Authorization: Bearer header", + )), } } @@ -365,13 +363,14 @@ mod tests { } #[test] - fn join_token_is_read_only_and_rejected_on_control() { + 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. + // …but never control (on a gated Director). let err = store.authenticate_control(Some(&token)).unwrap_err(); assert_eq!(err.code, ErrorCode::Unauthorized); } @@ -412,39 +411,45 @@ mod tests { } #[test] - fn a_present_but_invalid_token_is_rejected_even_on_an_open_director() { - // Even with nothing configured (open), a *presented* token must be valid — a stale - // token never silently succeeds. + 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_err().code, - ErrorCode::Unauthorized + store.authenticate_control(Some("stale")).unwrap().role, + Role::Rd ); - // A read-only token presented on control is still rejected, open or not. - let join = store.issue_join_token(); + 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_err().code, - ErrorCode::Unauthorized + store.authenticate_control(Some(&join)).unwrap().role, + Role::Rd ); } #[test] - fn revoked_token_stops_working() { + 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() { + 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 diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index 48b2945..3d429d5 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -314,9 +314,12 @@ async fn control_post_requires_a_valid_rd_token() { // A valid RD token is admitted. assert_eq!(post_status(&addr, &cmd, Some(&rd)).await, 200); - // Revoke the RD token; it stops working. + // 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 From 8039d0caa1f0cda202a775a3f7ede73bf6c5a3b6 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 21:38:35 +0000 Subject: [PATCH 056/362] =?UTF-8?q?Make=20the=20active=20event=20Director?= =?UTF-8?q?=20state=20=E2=80=94=20resume=20across=20reloads=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected event was per-client (browser sessionStorage), so a reload, reconnect, or Tauri app-restart dropped to the picker. But there is exactly one Race Director on one event, so the active event is the Director's state. Backend: - EventRegistry gains an `active_event: Option` behind its lock with `active()` / `set_active(id)` (set validates the event exists, else error). - Persisted to `/active-event` and restored on boot (a stale id degrades to the picker); in-memory only with no data dir. - Routes: `GET /active-event` → `{ event: EventMeta | null }` (open read); `PUT /active-event` `{ id }` (ControlAuth-gated) → the EventMeta, 404 on an unknown id. New wire types ActiveEvent / SetActiveEventRequest (bindings regenerated); `/active-event` added to the API-path set. Frontend: - protocol-client: `getActiveEvent()` + `setActiveEvent(id)`. - session: `resolveActiveEvent()` on load resumes into the active event or falls to the picker (`resolvingActiveEvent` drives a brief loading state); `chooseEvent()` persists via `PUT /active-event` (full-trust first, lazy token + retry on a gated Director) then enters. Creating an event persists it active too. "Switch event" (leaveEvent) is a client-side return to the picker that does NOT clear the server active event, so a reload mid-switch resumes and pilot/read clients aren't disrupted. - App shows a loading state while resolving, then workspace or picker. Tests: registry + route unit tests; session resume/choose/switch tests; a contract case for GET/PUT /active-event resume semantics; the browser e2e now reloads mid-event and asserts it resumes into the workspace. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ActiveEvent.ts | 18 +++ bindings/SetActiveEventRequest.ts | 12 ++ crates/server/src/app.rs | 126 +++++++++++++++- crates/server/src/events.rs | 142 ++++++++++++++++++ frontend/apps/rd-console/src/App.svelte | 49 +++++- .../apps/rd-console/src/lib/session.svelte.ts | 98 +++++++++++- .../rd-console/src/screens/EventPicker.svelte | 12 +- .../rd-console/tests/session.svelte.test.ts | 121 +++++++++++++++ frontend/contract/events.contract.ts | 68 +++++++++ frontend/e2e/race.spec.ts | 9 ++ .../packages/protocol-client/src/client.ts | 49 ++++++ .../packages/protocol-client/src/index.ts | 9 +- frontend/packages/types/src/generated.ts | 2 + 13 files changed, 707 insertions(+), 8 deletions(-) create mode 100644 bindings/ActiveEvent.ts create mode 100644 bindings/SetActiveEventRequest.ts 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/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/crates/server/src/app.rs b/crates/server/src/app.rs index 20f66c2..270c5f5 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -89,7 +89,9 @@ use tokio::sync::Notify; use crate::auth::{JoinTokenResponse, TokenStore}; use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; -use crate::events::{CreateEventRequest, EventMeta, EventRegistry}; +use crate::events::{ + ActiveEvent, CreateEventRequest, EventMeta, EventRegistry, SetActiveEventRequest, +}; use crate::live_state::live_state; use crate::scope::{ClassId, EventId, PilotId}; use crate::snapshot::{ProjectionBody, Snapshot}; @@ -284,6 +286,9 @@ pub fn router(registry: EventRegistry) -> Router { .route("/health", get(|| async { "ok" })) // Events lifecycle (issue #72): list (Practice first) and RD-gated create. .route("/events", get(list_events).post(create_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)) // Per-event read/realtime surface — `{event_id}` resolves to that event's log. .route( "/events/{event_id}/snapshot/event/{event}", @@ -356,6 +361,36 @@ async fn create_event( Ok(Json(meta)) } +/// `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(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; + Ok(Json(meta)) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// @@ -389,9 +424,10 @@ pub fn is_api_path(path: &str) -> bool { // `/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; 6] = [ + const API_PREFIXES: [&str; 7] = [ "/health", "/events", + "/active-event", "/snapshot", "/stream", "/control", @@ -1151,6 +1187,92 @@ mod tests { 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); + } + // --- #63: minting a read-only join token over HTTP ---------------------------------- /// `POST /auth/join-token` with an optional bearer token; returns status + parsed body. diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 8fb497c..4e62df6 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -51,6 +51,10 @@ 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 metadata describing one event in the registry (issue #72). /// /// The wire shape `GET /events` returns: a stable [`EventId`], a human display `name`, the @@ -93,6 +97,30 @@ pub struct EventMeta { pub organizer: Option, } +/// 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 `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 @@ -155,6 +183,11 @@ struct Registry { /// 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 { @@ -195,15 +228,57 @@ impl EventRegistry { })?; } + // 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, data_dir, + active_event, })), }) } + /// 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(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(format!("could not persist active event: {e}")))?; + } + Ok(meta) + } + /// 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 { @@ -312,6 +387,29 @@ fn event_db_path(dir: &Path, id: &EventId) -> PathBuf { dir.join(format!("{}.sqlite", id.0)) } +/// 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 creating an event or its registry (a storage failure, a bad data dir). #[derive(Debug, Clone)] pub struct RegistryError(pub String); @@ -477,6 +575,50 @@ mod tests { 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… + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + // …as long as that event still exists. The created event's SQLite file is on disk, + // but the registry only re-seeds Practice on boot (created events are not re-listed + // here), so a created-id active pointer is dropped as stale — assert Practice instead. + assert!(reopened.active().is_none()); + + // 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 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())); diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index f646f1c..074d986 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -34,6 +34,14 @@ let tokenDialog = $state(); session.setTokenProvider(() => tokenDialog?.request() ?? Promise.resolve(undefined)); + // Resume into the Director's active event on load (#90): the active event is server-side + // state, so a reload/reconnect/app-restart reads `GET /active-event` and re-enters the same + // event instead of dropping to the picker. While this resolves we show a brief loading state; + // it then settles into the workspace (active set) or the picker (none / unreachable). + $effect(() => { + void session.resolveActiveEvent(); + }); + type ScreenId = 'setup' | 'registration' | 'live' | 'marshaling' | 'results'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ { id: 'setup', label: 'Setup', key: '1', icon: 'M4 6h16M4 12h16M4 18h10' }, @@ -101,7 +109,16 @@ -{#if !session.currentEvent} +{#if session.resolvingActiveEvent && !session.currentEvent} + +
    +
    + + Resuming… +
    +
    +{:else if !session.currentEvent}
    @@ -246,6 +263,36 @@ diff --git a/frontend/apps/rd-console/src/screens/Timers.svelte b/frontend/apps/rd-console/src/screens/Timers.svelte new file mode 100644 index 0000000..b70a0d8 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/Timers.svelte @@ -0,0 +1,438 @@ + + + (formOpen = false)}> +
    +

    + Timers are configured once and reused across events. Each event picks which timers it uses; + the built-in Mock source flies a synthetic race with no hardware. +

    + + {#if loadState.kind === 'loading'} +
    + + Loading timers… +
    + {:else if loadState.kind === 'error'} + +
    +

    Couldn't load the timers.

    + {loadState.message} + +
    +
    + {:else if loadState.timers.length === 0} +
    +

    No timers configured.

    +

    Add one to give your events a lap source.

    +
    + {:else} +
      + {#each loadState.timers as timer (timer.id)} +
    • +
      +
      + {timer.name} + {kindLabel(timer.kind)} + {#if isBuiltInMock(timer)}Built-in{/if} +
      + {kindSummary(timer.kind)} +
      + +
      + + {#if !isBuiltInMock(timer)} + + {/if} +
      +
    • + {/each} +
    + {/if} +
    + + {#snippet footer()} + + + {/snippet} +
    + + + +
    + + + + + + + + + {#if formKind === 'Mock'} +
    + + + + + + +
    + {:else} + + + + {/if} +
    + {#snippet footer()} + + + {/snippet} +
    + + diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 31c32fe..54c2587 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; -import type { CommandAck, EventMeta } from '@gridfpv/types'; +import type { CommandAck, EventMeta, Timer } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; import { liveRunning, okAck, failAck } from './fixtures.js'; @@ -449,4 +449,126 @@ describe('Session', () => { eventId: 'practice' }); }); + + // ── Timer registry (issue #73) ───────────────────────────────────────────── + describe('timers', () => { + const MOCK_TIMER: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready' + }; + + function timerSession(overrides?: { + listTimersImpl?: ReturnType; + createTimerImpl?: ReturnType; + updateTimerImpl?: ReturnType; + deleteTimerImpl?: ReturnType; + setEventTimersImpl?: ReturnType; + }) { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + const session = new Session({ + connectImpl: connect, + controlFactory, + baseUrl: 'http://d.local', + autoRestore: false, + ...overrides + }); + return session; + } + + it('listTimers reads the registry open (no token)', async () => { + const listTimersImpl = vi.fn(async () => [MOCK_TIMER]); + const session = timerSession({ listTimersImpl }); + const timers = await session.listTimers(); + expect(timers).toEqual([MOCK_TIMER]); + expect(listTimersImpl).toHaveBeenCalledWith('http://d.local', { token: undefined }); + }); + + it('createTimer returns the new timer (full-trust, no token)', async () => { + const created: Timer = { + id: 'fast-x1', + name: 'Fast', + kind: { Mock: { laps: 5, lap_ms: 12000 } }, + status: 'Ready' + }; + const createTimerImpl = vi.fn(async () => created); + const session = timerSession({ createTimerImpl }); + const req = { name: 'Fast', kind: { Mock: { laps: 5, lap_ms: 12000 } } } as const; + const result = await session.createTimer(req); + expect(result).toEqual(created); + expect(createTimerImpl).toHaveBeenCalledWith('http://d.local', req, undefined); + }); + + it('createTimer prompts then retries once on an auth (401) failure', async () => { + const created: Timer = { + ...MOCK_TIMER, + id: 'rh-1', + name: 'RH', + kind: { Rotorhazard: { url: 'http://rh' } } + }; + const createTimerImpl = vi + .fn() + .mockRejectedValueOnce(new Error('POST /timers failed: HTTP 401')) + .mockResolvedValueOnce(created); + const session = timerSession({ createTimerImpl }); + session.setTokenProvider(async () => 'tok'); + const result = await session.createTimer({ + name: 'RH', + kind: { Rotorhazard: { url: 'http://rh' } } + }); + expect(result).toEqual(created); + expect(createTimerImpl).toHaveBeenCalledTimes(2); + // The retry carried the freshly-entered token. + expect(createTimerImpl).toHaveBeenLastCalledWith('http://d.local', expect.anything(), 'tok'); + }); + + it('createTimer resolves undefined when the auth prompt is cancelled', async () => { + const createTimerImpl = vi.fn().mockRejectedValue(new Error('POST /timers failed: HTTP 401')); + const session = timerSession({ createTimerImpl }); + session.setTokenProvider(async () => undefined); + const result = await session.createTimer({ + name: 'X', + kind: { Mock: { laps: 1, lap_ms: 1000 } } + }); + expect(result).toBeUndefined(); + expect(createTimerImpl).toHaveBeenCalledTimes(1); + }); + + it('deleteTimer re-throws a non-auth (400) failure for the UI to surface', async () => { + const deleteTimerImpl = vi + .fn() + .mockRejectedValue(new Error('DELETE /timers/mock failed: HTTP 400')); + const session = timerSession({ deleteTimerImpl }); + await expect(session.deleteTimer('mock')).rejects.toThrow(/400/); + }); + + it('setEventTimers is a no-op (undefined) with no event selected', async () => { + const setEventTimersImpl = vi.fn(); + const session = timerSession({ setEventTimersImpl }); + const result = await session.setEventTimers(['mock']); + expect(result).toBeUndefined(); + expect(setEventTimersImpl).not.toHaveBeenCalled(); + }); + + it('setEventTimers saves and re-homes currentEvent with the server response', async () => { + const updated: EventMeta = { ...PRACTICE, timers: ['mock', 'rh-1'] }; + const setEventTimersImpl = vi.fn(async () => updated); + const session = timerSession({ setEventTimersImpl }); + session.selectEvent(PRACTICE); + const result = await session.setEventTimers(['mock', 'rh-1']); + expect(result).toEqual(updated); + expect(setEventTimersImpl).toHaveBeenCalledWith( + 'http://d.local', + 'practice', + ['mock', 'rh-1'], + undefined + ); + expect(session.currentEvent?.timers).toEqual(['mock', 'rh-1']); + }); + }); }); From a1123909ead21751c1b78ad1bec50634823855ff Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 23:22:35 +0000 Subject: [PATCH 063/362] Timer CRUD inside an event (+ per-event selection unified) (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the timer registry management (the list + add/edit/remove Dialog) out of Timers.svelte into a shared TimerManager.svelte, so the CRUD lives in exactly one place. The picker's Timers modal embeds it CRUD-only (no event context to select for); the in-event EventTimers screen embeds the same manager and layers per-event selection on top — a checkbox per row bound to EventMeta.timers (saved via setEventTimers), plus per-row edit/remove and an "Add timer" action. After any create/edit/delete the manager reloads and hands the fresh list back, so the in-event working selection is reconciled (a removed timer drops out; a new one becomes available to tick). Existing save/selection semantics are kept: no empty selection, Save enables on change. Tests: add Timers.test.ts (picker CRUD modal) and EventTimers.test.ts (in-event CRUD + selection); extend the test session helper with timer seams and a custom event. Polyfill the native showModal/close in the vitest setup so Dialog-using screens render under jsdom. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventTimers.svelte | 269 +++------- .../src/screens/TimerManager.svelte | 462 ++++++++++++++++++ .../apps/rd-console/src/screens/Timers.svelte | 408 +--------------- .../apps/rd-console/tests/EventTimers.test.ts | 120 +++++ frontend/apps/rd-console/tests/Timers.test.ts | 106 ++++ frontend/apps/rd-console/tests/support.ts | 36 +- frontend/apps/rd-console/vitest.setup.ts | 23 + 7 files changed, 829 insertions(+), 595 deletions(-) create mode 100644 frontend/apps/rd-console/src/screens/TimerManager.svelte create mode 100644 frontend/apps/rd-console/tests/EventTimers.test.ts create mode 100644 frontend/apps/rd-console/tests/Timers.test.ts diff --git a/frontend/apps/rd-console/src/screens/EventTimers.svelte b/frontend/apps/rd-console/src/screens/EventTimers.svelte index 17b1ea7..c4f85bd 100644 --- a/frontend/apps/rd-console/src/screens/EventTimers.svelte +++ b/frontend/apps/rd-console/src/screens/EventTimers.svelte @@ -1,32 +1,31 @@ + +
    + {#if loadState.kind === 'loading'} +
    + + Loading timers… +
    + {:else if loadState.kind === 'error'} + +
    +

    Couldn't load the timers.

    + {loadState.message} + +
    +
    + {:else if loadState.timers.length === 0} +
    +

    No timers configured.

    +

    Add one to give your events a lap source.

    +
    + {:else} + {@render listHeader?.()} +
      + {#each loadState.timers as timer (timer.id)} +
    • + {@render rowLead?.(timer)} +
      +
      + {timer.name} + {kindLabel(timer.kind)} + {#if isBuiltInMock(timer)}Built-in{/if} +
      + {kindSummary(timer.kind)} +
      + +
      + + {#if !isBuiltInMock(timer)} + + {/if} +
      +
    • + {/each} +
    + {@render listFooter?.()} + {/if} +
    + + + +
    + + + + + + + + + {#if formKind === 'Mock'} +
    + + + + + + +
    + {:else} + + + + {/if} +
    + {#snippet footer()} + + + {/snippet} +
    + + diff --git a/frontend/apps/rd-console/src/screens/Timers.svelte b/frontend/apps/rd-console/src/screens/Timers.svelte index b70a0d8..8337ac1 100644 --- a/frontend/apps/rd-console/src/screens/Timers.svelte +++ b/frontend/apps/rd-console/src/screens/Timers.svelte @@ -1,305 +1,44 @@ - (formOpen = false)}> +

    Timers are configured once and reused across events. Each event picks which timers it uses; the built-in Mock source flies a synthetic race with no hardware.

    - {#if loadState.kind === 'loading'} -
    - - Loading timers… -
    - {:else if loadState.kind === 'error'} - -
    -

    Couldn't load the timers.

    - {loadState.message} - -
    -
    - {:else if loadState.timers.length === 0} -
    -

    No timers configured.

    -

    Add one to give your events a lap source.

    -
    - {:else} -
      - {#each loadState.timers as timer (timer.id)} -
    • -
      -
      - {timer.name} - {kindLabel(timer.kind)} - {#if isBuiltInMock(timer)}Built-in{/if} -
      - {kindSummary(timer.kind)} -
      - -
      - - {#if !isBuiltInMock(timer)} - - {/if} -
      -
    • - {/each} -
    - {/if} +
    {#snippet footer()} - - {/snippet} -
    - - - -
    - - - - - - - - - {#if formKind === 'Mock'} -
    - - - - - - -
    - {:else} - - - - {/if} -
    - {#snippet footer()} - - + {/snippet}
    @@ -318,121 +57,4 @@ color: var(--gf-text-secondary); font-weight: var(--gf-font-weight-semibold); } - - .state-msg { - display: flex; - align-items: center; - gap: var(--gf-space-3); - color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); - padding: var(--gf-space-4) 0; - } - .spinner { - width: 1.1rem; - height: 1.1rem; - border-radius: 50%; - border: 2px solid var(--gf-border); - border-top-color: var(--gf-accent); - animation: timers-spin 0.7s linear infinite; - } - @keyframes timers-spin { - to { - transform: rotate(360deg); - } - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation-duration: 2s; - } - } - .state-error { - display: flex; - flex-direction: column; - gap: var(--gf-space-3); - align-items: flex-start; - } - .state-error p { - margin: 0; - font-weight: var(--gf-font-weight-semibold); - } - .state-error code { - font-size: var(--gf-font-size-xs); - color: var(--gf-danger); - word-break: break-all; - } - - .empty { - display: flex; - flex-direction: column; - gap: var(--gf-space-1); - padding: var(--gf-space-5); - border: 1px dashed var(--gf-border); - border-radius: var(--gf-radius-lg); - background: var(--gf-surface-alt); - } - .empty p { - margin: 0; - font-weight: var(--gf-font-weight-medium); - } - .empty-sub { - color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); - font-weight: var(--gf-font-weight-regular) !important; - } - - .timer-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--gf-space-3); - } - .timer-row { - display: flex; - align-items: center; - gap: var(--gf-space-4); - padding: var(--gf-space-3) var(--gf-space-4); - border: 1px solid var(--gf-border); - border-radius: var(--gf-radius-lg); - background: var(--gf-surface); - } - .timer-main { - display: flex; - flex-direction: column; - gap: 3px; - flex: 1; - min-width: 0; - } - .timer-head { - display: flex; - align-items: center; - gap: var(--gf-space-2); - flex-wrap: wrap; - } - .timer-name { - font-size: var(--gf-font-size-md); - font-weight: var(--gf-font-weight-semibold); - letter-spacing: var(--gf-tracking-tight); - } - .timer-sub { - font-size: var(--gf-font-size-xs); - color: var(--gf-text-muted); - } - .timer-actions { - display: flex; - gap: var(--gf-space-2); - flex-shrink: 0; - } - - .timer-form { - display: flex; - flex-direction: column; - gap: var(--gf-space-3); - } - .kind-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--gf-space-3); - } diff --git a/frontend/apps/rd-console/tests/EventTimers.test.ts b/frontend/apps/rd-console/tests/EventTimers.test.ts new file mode 100644 index 0000000..cf19523 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventTimers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { EventMeta, Timer } from '@gridfpv/types'; +import EventTimers from '../src/screens/EventTimers.svelte'; +import { makeTestSession } from './support.js'; + +const MOCK: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready' +}; +const RH: Timer = { + id: 'rh-1', + name: 'Track RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Disconnected' +}; + +/** A created event selecting only the Mock timer (so its checkbox seeds checked). */ +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'] +}; + +describe('EventTimers (in-event CRUD + selection)', () => { + it('seeds the checkboxes from the event selection', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const { session } = makeTestSession({ listTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const mockBox = (await screen.findByLabelText('Use Mock')) as HTMLInputElement; + const rhBox = screen.getByLabelText('Use Track RH') as HTMLInputElement; + expect(mockBox.checked).toBe(true); + expect(rhBox.checked).toBe(false); + // No change yet → Save disabled. + expect( + (screen.getByRole('button', { name: 'Save selection' }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + it('saves the working selection in the registry-listed order on change', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const setEventTimersImpl = vi.fn(async () => ({ ...EVENT, timers: ['mock', 'rh-1'] })); + const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const rhBox = (await screen.findByLabelText('Use Track RH')) as HTMLInputElement; + await fireEvent.click(rhBox); + + const save = screen.getByRole('button', { name: 'Save selection' }) as HTMLButtonElement; + expect(save.disabled).toBe(false); + await fireEvent.click(save); + + await waitFor(() => expect(setEventTimersImpl).toHaveBeenCalledTimes(1)); + expect(setEventTimersImpl).toHaveBeenCalledWith( + 'http://d.local', + 'e1', + ['mock', 'rh-1'], + 'tok' + ); + }); + + it('blocks saving an empty selection', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const setEventTimersImpl = vi.fn(async () => EVENT); + const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const mockBox = (await screen.findByLabelText('Use Mock')) as HTMLInputElement; + await fireEvent.click(mockBox); // unselect the only selected → empty + await fireEvent.click(screen.getByRole('button', { name: 'Save selection' })); + + // The empty selection is rejected client-side; no protocol call goes out. + expect(setEventTimersImpl).not.toHaveBeenCalled(); + }); + + it('adds a timer from inside the event (shared management)', async () => { + const created: Timer = { + id: 'fast-x', + name: 'Fast', + kind: { Mock: { laps: 5, lap_ms: 12000 } }, + status: 'Ready' + }; + let calls = 0; + const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); + const createTimerImpl = vi.fn(async () => created); + const { session } = makeTestSession({ listTimersImpl, createTimerImpl, event: EVENT }); + render(EventTimers, { session }); + + await screen.findByLabelText('Use Mock'); + await fireEvent.click(screen.getByRole('button', { name: '+ Add timer' })); + + const name = (await screen.findByLabelText('Timer name')) as HTMLInputElement; + await fireEvent.input(name, { target: { value: 'Fast' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add timer' })); + + await waitFor(() => expect(createTimerImpl).toHaveBeenCalledTimes(1)); + // The new timer becomes available to select (a fresh, unchecked row). + const newBox = (await screen.findByLabelText('Use Fast')) as HTMLInputElement; + expect(newBox.checked).toBe(false); + }); + + it('shows per-row edit, and hides Remove for the built-in Mock', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const { session } = makeTestSession({ listTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const list = await screen.findByRole('list', { name: 'Configured timers' }); + const rows = within(list).getAllByRole('listitem'); + // Both rows have Edit; only the RH row has Remove. + expect(within(rows[0]).getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(within(rows[0]).queryByRole('button', { name: 'Remove' })).toBeNull(); + expect(within(rows[1]).getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/Timers.test.ts b/frontend/apps/rd-console/tests/Timers.test.ts new file mode 100644 index 0000000..0ff0553 --- /dev/null +++ b/frontend/apps/rd-console/tests/Timers.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { Timer } from '@gridfpv/types'; +import Timers from '../src/screens/Timers.svelte'; +import { makeTestSession } from './support.js'; + +const MOCK: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready' +}; +const RH: Timer = { + id: 'rh-1', + name: 'Track RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Disconnected' +}; + +describe('Timers (picker CRUD modal)', () => { + it('lists the registry on open, with the built-in Mock undeletable', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const { session } = makeTestSession({ listTimersImpl }); + render(Timers, { session, open: true }); + + await screen.findByText('Mock'); + expect(screen.getByText('Track RH')).toBeInTheDocument(); + + const list = screen.getByRole('list', { name: 'Configured timers' }); + const rows = within(list).getAllByRole('listitem'); + // Mock row: no Remove button (built-in). RH row: has one. + expect(within(rows[0]).queryByRole('button', { name: 'Remove' })).toBeNull(); + expect(within(rows[1]).getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); + + it('adds a timer and reloads the list', async () => { + const created: Timer = { + id: 'fast-x', + name: 'Fast', + kind: { Mock: { laps: 5, lap_ms: 12000 } }, + status: 'Ready' + }; + let calls = 0; + const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); + const createTimerImpl = vi.fn(async () => created); + const { session } = makeTestSession({ listTimersImpl, createTimerImpl }); + render(Timers, { session, open: true }); + + await screen.findByText('Mock'); + await fireEvent.click(screen.getByRole('button', { name: '+ Add timer' })); + + const name = (await screen.findByLabelText('Timer name')) as HTMLInputElement; + await fireEvent.input(name, { target: { value: 'Fast' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add timer' })); + + await waitFor(() => expect(createTimerImpl).toHaveBeenCalledTimes(1)); + expect(createTimerImpl).toHaveBeenCalledWith( + 'http://d.local', + { name: 'Fast', kind: { Mock: { laps: 3, lap_ms: 30000 } } }, + 'tok' + ); + // The list reloaded and shows the new timer. + await screen.findByText('Fast'); + }); + + it('edits a timer through the same dialog', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const updateTimerImpl = vi.fn(async () => ({ ...RH, name: 'Renamed' })); + const { session } = makeTestSession({ listTimersImpl, updateTimerImpl }); + render(Timers, { session, open: true }); + + await screen.findByText('Track RH'); + const list = screen.getByRole('list', { name: 'Configured timers' }); + const rhRow = within(list).getAllByRole('listitem')[1]; + await fireEvent.click(within(rhRow).getByRole('button', { name: 'Edit' })); + + const name = (await screen.findByLabelText('Timer name')) as HTMLInputElement; + expect(name.value).toBe('Track RH'); + await fireEvent.input(name, { target: { value: 'Renamed' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(updateTimerImpl).toHaveBeenCalledTimes(1)); + expect(updateTimerImpl).toHaveBeenCalledWith( + 'http://d.local', + 'rh-1', + { name: 'Renamed', kind: { Rotorhazard: { url: 'http://rh.local:5000' } } }, + 'tok' + ); + }); + + it('removes a non-built-in timer', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const deleteTimerImpl = vi.fn(async () => undefined as unknown as void); + const { session } = makeTestSession({ listTimersImpl, deleteTimerImpl }); + render(Timers, { session, open: true }); + + await screen.findByText('Track RH'); + const list = screen.getByRole('list', { name: 'Configured timers' }); + const rhRow = within(list).getAllByRole('listitem')[1]; + await fireEvent.click(within(rhRow).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(deleteTimerImpl).toHaveBeenCalledTimes(1)); + expect(deleteTimerImpl).toHaveBeenCalledWith('http://d.local', 'rh-1', 'tok'); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 1ca4b32..5e8926a 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -4,7 +4,16 @@ * screen emits; `pushLive` injects a `LiveRaceState` onto the read stream. */ import { vi } from 'vitest'; -import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; +import type { + ProtocolClient, + ProtocolState, + StateListener, + listTimers, + createTimer, + updateTimer, + deleteTimer, + setEventTimers +} from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -17,13 +26,24 @@ const PRACTICE: EventMeta = { timers: ['mock'] }; +/** The timer-registry seams a screen test can override (all optional; defaults are inert). */ +export interface TimerImpls { + listTimersImpl?: typeof listTimers; + createTimerImpl?: typeof createTimer; + updateTimerImpl?: typeof updateTimer; + deleteTimerImpl?: typeof deleteTimer; + setEventTimersImpl?: typeof setEventTimers; +} + export interface TestSession { session: Session; sendSpy: ReturnType Promise>>; pushLive: (state: LiveRaceState) => void; } -export function makeTestSession(opts?: { ack?: CommandAck; live?: LiveRaceState }): TestSession { +export function makeTestSession( + opts?: { ack?: CommandAck; live?: LiveRaceState; event?: EventMeta } & TimerImpls +): TestSession { const ack: CommandAck = opts?.ack ?? { ok: true }; const sendSpy = vi.fn<(c: Command) => Promise>(async () => ack); @@ -50,11 +70,17 @@ export function makeTestSession(opts?: { ack?: CommandAck; live?: LiveRaceState connectImpl: () => client, controlFactory: () => ({ baseUrl: 'http://d.local', sendCommand: sendSpy }), baseUrl: 'http://d.local', - autoRestore: false + autoRestore: false, + // Timer-registry seams (issue #73): inert unless a test overrides them. + listTimersImpl: opts?.listTimersImpl, + createTimerImpl: opts?.createTimerImpl, + updateTimerImpl: opts?.updateTimerImpl, + deleteTimerImpl: opts?.deleteTimerImpl, + setEventTimersImpl: opts?.setEventTimersImpl }); - // Seed a token (so privileged sends don't trigger the lazy prompt) and enter Practice. + // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event. session.setToken('tok'); - session.selectEvent(PRACTICE); + session.selectEvent(opts?.event ?? PRACTICE); const pushLive = (state: LiveRaceState) => listener?.({ body: { LiveRaceState: state }, cursor: 1, status: 'live', error: undefined }); diff --git a/frontend/apps/rd-console/vitest.setup.ts b/frontend/apps/rd-console/vitest.setup.ts index ae97d62..e0dc360 100644 --- a/frontend/apps/rd-console/vitest.setup.ts +++ b/frontend/apps/rd-console/vitest.setup.ts @@ -1,3 +1,26 @@ // Extends Vitest's `expect` with jest-dom matchers (toBeInTheDocument, etc.) and // auto-cleans the rendered DOM between tests (via @testing-library/svelte/vite). import '@testing-library/jest-dom/vitest'; + +// jsdom doesn't implement the native modal API, so screens that use our Dialog +// primitive (Timers, EventTimers, …) can't render under test without this shim. Drive the +// `open` reflected attribute the way a real would, so `el.open` and our toggle +// effect behave; we don't need true top-layer/backdrop semantics in unit tests. +if (typeof HTMLDialogElement !== 'undefined') { + if (!HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + } + if (!HTMLDialogElement.prototype.show) { + HTMLDialogElement.prototype.show = function show(this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + } + if (!HTMLDialogElement.prototype.close) { + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { + this.removeAttribute('open'); + this.dispatchEvent(new Event('close')); + }; + } +} From d91e4873dc48b260ea0d6fdde8d1e7cf737ea7bf Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 23:25:46 +0000 Subject: [PATCH 064/362] test: RH timer fixtures use a valid TimerStatus ('Configured', not 'Disconnected') Fixes the #98 frontend CI: svelte-check (test tsconfig) rejected the invalid 'Disconnected' status. TimerStatus is 'Ready' | 'Configured'. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/tests/EventTimers.test.ts | 2 +- frontend/apps/rd-console/tests/Timers.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/apps/rd-console/tests/EventTimers.test.ts b/frontend/apps/rd-console/tests/EventTimers.test.ts index cf19523..cd49e26 100644 --- a/frontend/apps/rd-console/tests/EventTimers.test.ts +++ b/frontend/apps/rd-console/tests/EventTimers.test.ts @@ -15,7 +15,7 @@ const RH: Timer = { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Disconnected' + status: 'Configured' }; /** A created event selecting only the Mock timer (so its checkbox seeds checked). */ diff --git a/frontend/apps/rd-console/tests/Timers.test.ts b/frontend/apps/rd-console/tests/Timers.test.ts index 0ff0553..271c952 100644 --- a/frontend/apps/rd-console/tests/Timers.test.ts +++ b/frontend/apps/rd-console/tests/Timers.test.ts @@ -15,7 +15,7 @@ const RH: Timer = { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Disconnected' + status: 'Configured' }; describe('Timers (picker CRUD modal)', () => { From 50bb580081cd867cf92bec34c834d5cbaecd6688 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 23:32:20 +0000 Subject: [PATCH 065/362] fix(rd-console): timer delete shows success, not a 'token required' toast (#99) deleteTimer now resolves to true on success (the DELETE has no body), so the remove() handler can tell success from a cancelled token prompt (undefined). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/lib/session.svelte.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 59144a6..cef7f94 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -321,10 +321,15 @@ export class Session { /** * Remove a timer (`DELETE /timers/{id}`). The built-in **Mock cannot be deleted** (the Director * answers **400**); that surfaces to the caller as a thrown error to handle gracefully. Resolves - * once the delete succeeds, `undefined` on a cancelled prompt, or throws on any other failure. + * to `true` once the delete succeeds, `undefined` on a cancelled token prompt, or throws on any + * other failure. (Returning `true` lets callers tell success apart from a cancelled prompt — the + * `DELETE` itself has no body, so a raw `void` would be indistinguishable from the cancel case.) */ - deleteTimer(id: TimerId): Promise { - return this.#privilegedWrite((token) => this.#deleteTimerImpl(this.baseUrl, id, token)); + deleteTimer(id: TimerId): Promise { + return this.#privilegedWrite(async (token) => { + await this.#deleteTimerImpl(this.baseUrl, id, token); + return true as const; + }); } /** From 1de93002521ed95cb395c2902392f908d46732b9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 20 Jun 2026 23:50:32 +0000 Subject: [PATCH 066/362] rd-console: put Practice below 'Your events' in the picker Feedback: when running real events, your events should lead; Practice is the secondary scratch option, now under its own label below the events list. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventPicker.svelte | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index 1a71ebd..9e336b3 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -205,26 +205,6 @@
    {:else} - {#if practice} -
    - -
    - {/if} -

    Your events

    {#if others.length === 0} @@ -253,6 +233,27 @@ {/if}
    + + {#if practice} +
    +

    Practice

    + +
    + {/if} {/if}
    From 5278dc7af6615f6781d15bbdee93268fef9d11d9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:01:42 +0000 Subject: [PATCH 067/362] Build plumbing: vendored-OpenSSL live TLS + gridfpv-app `live` feature (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the adapters `live` feature vendor OpenSSL on Linux and add a matching `live` feature to `gridfpv-app` so the real RotorHazard adapter can be compiled into the Director without putting openssl in the default graph. - crates/adapters: declare `native-tls` with `features = ["vendored"]` and include it in `live`. native-tls is SChannel (Win) / Secure Transport (mac) / OpenSSL (Linux); `vendored` statically compiles OpenSSL into the binary on Linux (self-contained AppImage) and is a no-op on Win/Mac. - crates/app: add a `live` feature enabling `gridfpv-adapters/live`; the default build stays Mock/sim-only and openssl-free (gridfpv-adapters is an optional dep, only pulled under `--features live`). Proof the live binary is self-contained: $ cargo build -p gridfpv-app --features live $ ldd target/debug/gridfpv | grep -i ssl # (no match — no libssl) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 12 ++++++++++++ crates/adapters/Cargo.toml | 13 ++++++++++++- crates/app/Cargo.toml | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 21d52a9..92e7394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,7 @@ dependencies = [ "gridfpv-events", "gridfpv-projection", "gridfpv-testkit", + "native-tls", "rust_socketio", "serde", "serde_json", @@ -459,6 +460,7 @@ dependencies = [ "gridfpv-projection", "gridfpv-server", "gridfpv-storage", + "gridfpv-testkit", "http-body-util", "serde_json", "tokio", @@ -982,6 +984,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" @@ -990,6 +1001,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] diff --git a/crates/adapters/Cargo.toml b/crates/adapters/Cargo.toml index 675475a..0f5f172 100644 --- a/crates/adapters/Cargo.toml +++ b/crates/adapters/Cargo.toml @@ -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/app/Cargo.toml b/crates/app/Cargo.toml index 3ef35bb..0bed71e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -10,10 +10,23 @@ 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). The **default build stays OpenSSL-free** — Mock/sim +# only, no network/TLS in the dependency graph — so the stock Director carries no openssl. Build +# the live variant with `cargo build -p gridfpv-app --features live`. +live = ["dep:gridfpv-adapters", "gridfpv-adapters/live"] + [dependencies] gridfpv-events.workspace = true gridfpv-storage.workspace = true gridfpv-projection.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 @@ -29,6 +42,9 @@ tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } [dev-dependencies] serde_json.workspace = true gridfpv-adapters.workspace = true +# 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"] } From e18f328c6b59bcb8a345c568c287209ae0ec5965 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:01:55 +0000 Subject: [PATCH 068/362] Live per-timer connection status: dynamic TimerStatus + set_status (#73, #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend TimerStatus with the dynamic connection states the Director drives on a live RotorHazard timer — Connecting | Connected | Disconnected | Error — keeping Ready (Mock) and Configured (un-connected RH). Add TimerRegistry::set_status so the source bridge can publish an RH timer's connection lifecycle live. The dynamic states are in-memory only (never persisted): a reopen restores a timer's resting status from its kind, so a stale Connected/Error is never read back. The new variants are additive on the wire (a console that only knows Ready/Configured still parses the type). GET /timers reflects current status live, so the console can poll a drop-off. Bindings regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/TimerStatus.ts | 21 +++++++-- crates/server/src/timers.rs | 91 +++++++++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/bindings/TimerStatus.ts b/bindings/TimerStatus.ts index fd0d742..8d0af2a 100644 --- a/bindings/TimerStatus.ts +++ b/bindings/TimerStatus.ts @@ -1,10 +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 (issue #73). + * Whether a timer is currently usable, and — for a live source — the state of its connection + * (issues #73, #65). * - * The Mock is always [`Ready`](TimerStatus::Ready) (it needs nothing external). A reserved - * RotorHazard timer reports [`Configured`](TimerStatus::Configured) — it has a URL on file but is - * not yet connected (2b wires the live connection and the `Connected`/`Unreachable` states). + * 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"; +export type TimerStatus = "Ready" | "Configured" | "Connecting" | "Connected" | "Disconnected" | "Error"; diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index f666cf7..75b6ec2 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -81,18 +81,37 @@ pub enum TimerKind { }, } -/// Whether a timer is currently usable (issue #73). +/// Whether a timer is currently usable, and — for a live source — the state of its connection +/// (issues #73, #65). /// -/// The Mock is always [`Ready`](TimerStatus::Ready) (it needs nothing external). A reserved -/// RotorHazard timer reports [`Configured`](TimerStatus::Configured) — it has a URL on file but is -/// not yet connected (2b wires the live connection and the `Connected`/`Unreachable` states). +/// 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 reserved RotorHazard timer; 2b connects it). + /// 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, } /// One configured timer in the application-level registry (issue #73). @@ -308,6 +327,18 @@ impl TimerRegistry { 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; + } + } + /// 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. @@ -573,6 +604,56 @@ mod tests { 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 a_corrupt_timers_file_degrades_to_just_the_mock() { let dir = std::env::temp_dir().join(format!("gridfpv-timers-bad-{}", short_suffix())); From 9bb35337772c8366c7e006f04d82c809c3b6d902 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:02:06 +0000 Subject: [PATCH 069/362] RotorHazard connects per-event under `live` + feeds passes into the log (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Director is built with `live` and an event's selected timers include a Rotorhazard { url }, the per-event source bridge now drives a real RhSource for it — mirroring the Mock bridge's per-event wiring. The RhSource connects through the adapters crate's RotorHazardConnection, resets + stages the race, drains the translated pass stream, and feeds passes into THAT event's log (remapping RH node seats onto the heat's lineup by index). It updates the timer's TimerStatus as the connection comes and goes (Connecting -> Connected -> Disconnected/Error) and tears the connection down when the heat leaves Running. The connection lifecycle runs on a dedicated spawn_blocking thread (the RH transport's emit/poll block on an internal runtime and must not run on a tokio worker); the async run_heat future flips a cancel flag on drop so an aborted heat tears down cleanly off-runtime. A non-`live` build keeps the Rotorhazard no-op stub; Mock is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/source.rs | 45 ++++- crates/app/src/source/rotorhazard.rs | 241 +++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 crates/app/src/source/rotorhazard.rs diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index ff24a32..51ff303 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -58,6 +58,11 @@ use gridfpv_server::timers::{TimerKind, TimerRegistry}; use gridfpv_storage::Offset; use tokio::task::JoinHandle; +#[cfg(feature = "live")] +mod rotorhazard; +#[cfg(feature = "live")] +pub use rotorhazard::RhSource; + /// 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. @@ -123,6 +128,26 @@ impl PassSink { .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> { + 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. @@ -406,13 +431,17 @@ pub(crate) async fn run_bridge( } } -/// Resolve the event's selected timers into the [`LapSource`]s to run for this heat (issue #73). +/// Resolve the event's selected timers into the [`LapSource`]s to run for this heat (issues #73, +/// #65). /// /// 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 skipped (a no-op stub for 2b / #65); an id -/// that no longer resolves (a since-deleted timer) is skipped too. Returns the sources to drive -/// concurrently for the running heat — empty when the event selects no usable timer. +/// `laps`/`lap_ms`. A selected **RotorHazard** timer maps to a live [`RhSource`] (under the `live` +/// feature) that connects to its `url`, drives the race, feeds RH passes into the event log, and +/// updates that timer's connection [`TimerStatus`](gridfpv_server::timers::TimerStatus) as it goes; +/// in a **non-`live`** build a RotorHazard timer is a no-op stub (skipped). An id that no longer +/// resolves (a since-deleted timer) is skipped too. Returns the sources to drive concurrently for +/// the running heat — empty when the event selects no usable timer. fn selected_sources( registry: &EventRegistry, timers: &TimerRegistry, @@ -433,8 +462,12 @@ fn selected_sources( Duration::from_millis(lap_ms), ))); } - // RotorHazard is a reserved no-op stub in this slice (2b / #65 connects it). - TimerKind::Rotorhazard { .. } => {} + // A live build connects the real RotorHazard; the default build keeps it a no-op stub. + TimerKind::Rotorhazard { url } => { + let _ = (&id, &url); + #[cfg(feature = "live")] + sources.push(Arc::new(RhSource::new(id, url, timers.clone()))); + } } } sources diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs new file mode 100644 index 0000000..b9b2c2d --- /dev/null +++ b/crates/app/src/source/rotorhazard.rs @@ -0,0 +1,241 @@ +//! The live **RotorHazard lap source** (#65, Slice 2b) — only compiled under `--features live`. +//! +//! This is the real counterpart to the [`SimSource`](super::SimSource): when an event selects a +//! [`Rotorhazard { url }`](gridfpv_server::timers::TimerKind::Rotorhazard) timer and a heat goes +//! `Running`, the per-event bridge ([`run_bridge`](super::run_bridge)) drives an [`RhSource`] for +//! it, exactly as it drives a `SimSource` for a Mock timer. The `RhSource`: +//! +//! 1. **Connects** to the RH server at `url` through the adapters crate's feature-gated +//! [`RotorHazardConnection`] (a Socket.IO transport behind the pure +//! [`RotorHazardAdapter`](gridfpv_adapters::rotorhazard::RotorHazardAdapter) translator). +//! 2. **Drives the race**: resets RH to a clean state, stages it, then polls the connection's +//! translated [`Event`] stream and **feeds the passes into the event's log** (the same log the +//! Mock bridge appends to, so `/stream` wakes and the console animates). RH node seats +//! (`node-0`, `node-1`, …) are remapped onto the running heat's lineup by index, so passes +//! attribute to the heat's actual competitors. +//! 3. **Tears down** on cancellation — when the heat leaves `Running` the bridge drops this +//! future; a cancel flag tells the driver thread to stop the RH race and disconnect. +//! +//! Throughout, it updates the timer's **live connection status** in the +//! [`TimerRegistry`](gridfpv_server::timers::TimerRegistry): +//! [`Connecting`](gridfpv_server::timers::TimerStatus::Connecting) → +//! [`Connected`](gridfpv_server::timers::TimerStatus::Connected) → +//! [`Disconnected`](gridfpv_server::timers::TimerStatus::Disconnected) (clean teardown) or +//! [`Error`](gridfpv_server::timers::TimerStatus::Error) (the connect failed). A fresh `GET /timers` +//! reflects the current value, so the console can poll a drop-off. +//! +//! # 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 → reset → stage → drain → stop → disconnect) therefore runs on one dedicated +//! [`spawn_blocking`](tokio::task::spawn_blocking) thread; the async `run_heat` future only awaits +//! it and flips a shared **cancel flag** when the bridge drops it. The driver checks that flag each +//! loop and tears down cleanly on its own thread, so an aborted future never emits on the runtime. + +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use gridfpv_adapters::rotorhazard::RotorHazardAdapter; +use gridfpv_adapters::rotorhazard::transport::RotorHazardConnection; +use gridfpv_events::{AdapterId, CompetitorRef, Event}; +use gridfpv_server::timers::{TimerId, TimerRegistry, TimerStatus}; + +use super::{HeatRun, LapSource, PassSink, SourceError}; + +/// How often the driver thread drains the RotorHazard connection's translated-event queue and +/// appends any new passes to the log. +const DRAIN_INTERVAL: Duration = Duration::from_millis(100); + +/// How long to wait, after staging, 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); + +/// A live RotorHazard lap source (feature `live`): connects to one RH server and feeds its passes +/// into the running heat's log, maintaining the timer's connection status. See the [module +/// docs](self). +pub struct RhSource { + /// The selected timer's id — the handle whose [`TimerStatus`] this source drives. + timer_id: TimerId, + /// The RH server base URL to dial (e.g. `http://rotorhazard.local:5000`). + url: String, + /// The app-level timer registry, so the source can publish its live connection status. + timers: TimerRegistry, +} + +impl RhSource { + /// A live RH source for `timer_id` pointed at `url`, publishing status through `timers`. + pub fn new(timer_id: TimerId, url: String, timers: TimerRegistry) -> Self { + Self { + timer_id, + url, + timers, + } + } + + /// 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 this source's adapter id, or + /// `None` to drop it. Only [`Event::Pass`]es feed the lap projection here; a pass on node `n` + /// is attributed to `lineup[n]` (its actual competitor) and re-stamped with `adapter`. Passes + /// for a node outside the lineup (an idle seat) are dropped. The adapter's lifecycle / + /// `CompetitorSeen` events are not appended — the heat's lineup is already established by the + /// control path, and the bridge owns the heat lifecycle. + fn remap(event: Event, lineup: &[CompetitorRef], adapter: &AdapterId) -> Option { + match event { + Event::Pass(mut pass) => { + let index = Self::node_index(&pass.competitor)?; + let competitor = lineup.get(index)?.clone(); + pass.adapter = adapter.clone(); + pass.competitor = competitor; + Some(Event::Pass(pass)) + } + _ => None, + } + } + + /// The blocking driver: the whole connection lifecycle on one dedicated thread (see the module + /// docs on why this must not run on a tokio worker). Runs until `cancel` is set, then tears + /// the connection down and marks the timer `Disconnected`. Returns the last error, if any. + fn drive( + url: String, + timer_id: TimerId, + timers: TimerRegistry, + sink: PassSink, + lineup: Vec, + cancel: Arc, + ) -> Result<(), SourceError> { + timers.set_status(&timer_id, TimerStatus::Connecting); + + let conn = match RotorHazardConnection::connect(&url, RotorHazardAdapter::new()) { + Ok(conn) => conn, + Err(e) => { + timers.set_status(&timer_id, TimerStatus::Error); + return Err(SourceError(format!("RotorHazard connect failed: {e}"))); + } + }; + timers.set_status(&timer_id, TimerStatus::Connected); + + // From here the connection is up: whatever happens, leave the timer Disconnected and stop + // the RH race on the way out. + let result = Self::drive_connected(&conn, &sink, &lineup, &cancel); + + conn.stop_race().ok(); + conn.disconnect().ok(); + timers.set_status(&timer_id, TimerStatus::Disconnected); + result + } + + /// The connected phase: reset → stage → drain passes into the log until cancelled. + fn drive_connected( + conn: &RotorHazardConnection, + sink: &PassSink, + lineup: &[CompetitorRef], + cancel: &AtomicBool, + ) -> Result<(), SourceError> { + // Reset RH to a clean READY state (a prior DONE race blocks staging), drop the reset-era + // event churn, then stage (RH auto-starts). + conn.stop_race().ok(); + conn.discard_laps().ok(); + Self::sleep_unless_cancelled(Duration::from_millis(400), cancel); + let _ = conn.events(); + if cancel.load(Ordering::Relaxed) { + return Ok(()); + } + let _ = conn.stage_race(); + + // Wait (bounded) for RACING so the first passes are real race passes, but keep draining + // throughout so nothing is dropped while we wait. + let adapter = sink.adapter().clone(); + let deadline = Instant::now() + STAGE_SETTLE; + let mut started = false; + loop { + if cancel.load(Ordering::Relaxed) { + return Ok(()); + } + for event in conn.events() { + if matches!(event, Event::SessionStarted { .. }) { + started = true; + } + if let Some(remapped) = Self::remap(event, lineup, &adapter) { + sink.append_event(remapped)?; + } + } + if started || Instant::now() >= deadline { + break; + } + Self::sleep_unless_cancelled(DRAIN_INTERVAL, cancel); + } + + // Steady-state drain until the bridge cancels us (the heat left Running). + while !cancel.load(Ordering::Relaxed) { + for event in conn.events() { + if let Some(remapped) = Self::remap(event, lineup, &adapter) { + sink.append_event(remapped)?; + } + } + Self::sleep_unless_cancelled(DRAIN_INTERVAL, cancel); + } + Ok(()) + } + + /// Sleep `dur`, but wake early (in short slices) if `cancel` flips, so teardown is prompt. + fn sleep_unless_cancelled(dur: Duration, cancel: &AtomicBool) { + let deadline = Instant::now() + dur; + while Instant::now() < deadline { + if cancel.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +impl LapSource for RhSource { + fn run_heat( + &self, + run: HeatRun, + sink: PassSink, + ) -> Pin> + Send>> { + let timer_id = self.timer_id.clone(); + let url = self.url.clone(); + let timers = self.timers.clone(); + Box::pin(async move { + // A shared cancel flag: the driver runs on a blocking thread; this future flips the + // flag when it is dropped (the bridge aborts it on the heat leaving Running) so the + // driver tears down on its own thread — never emitting on a tokio worker. + let cancel = Arc::new(AtomicBool::new(false)); + let _guard = CancelOnDrop(cancel.clone()); + + let lineup = run.lineup.clone(); + let driver = tokio::task::spawn_blocking(move || { + Self::drive(url, timer_id, timers, sink, lineup, cancel) + }); + + // Await the driver. If THIS future is dropped (cancelled), `_guard` flips the flag and + // the detached driver thread tears down cleanly; we simply never observe its result. + match driver.await { + Ok(result) => result, + Err(join) => Err(SourceError(format!( + "RotorHazard driver task failed: {join}" + ))), + } + }) + } +} + +/// Flips a cancel flag when dropped — the async future's hook to stop the blocking driver thread +/// on cancellation (the bridge aborting the heat task) without emitting on the runtime. +struct CancelOnDrop(Arc); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} From c7260bac418cf689d5b9a4896bae6693cc7da61f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:02:15 +0000 Subject: [PATCH 070/362] Dockerized-RH live test: Director connects + passes flow; wire into xtask (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `#[cfg(feature="live")]` `#[ignore]` e2e (tests/rh_connect_live.rs) that stands up a dockerized RotorHazard via the gridfpv-testkit RhContainer, configures an event with a Rotorhazard { url } timer pointing at it, spawns the real registry bridge, drives Practice's heat to Running, and asserts the Director connects (status -> Connected) and real RH passes flow into the event log (remapped onto the lineup), then tears down on Finish. Wired into `cargo xtask live`. Verified locally against Docker: connected, real pass(es) into the event log, then torn down — `test result: ok. 1 passed`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/tests/rh_connect_live.rs | 207 ++++++++++++++++++++++++++++ xtask/src/main.rs | 4 + 2 files changed, 211 insertions(+) create mode 100644 crates/app/tests/rh_connect_live.rs diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs new file mode 100644 index 0000000..2892ad9 --- /dev/null +++ b/crates/app/tests/rh_connect_live.rs @@ -0,0 +1,207 @@ +//! Dockerized-RotorHazard **Director connect** e2e (#65, #73 — Slice 2b backend). +//! +//! The end-to-end proof that the Director's per-event source bridge *actually connects* a real +//! RotorHazard and feeds its passes into the event's log — 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, and **selects it for Practice** (the per-event selection the bridge reads). +//! 3. Spawns the **real** registry bridge ([`spawn_registry_bridge`]) — the same wiring `main` +//! runs — then drives Practice's heat to `Running` through its log (what the control path +//! appends). +//! 4. Asserts the Director **connects**: the timer's [`TimerStatus`] advances to `Connected`, and +//! **passes flow** into Practice's log (real RH crossings, attributed to the heat's lineup). +//! 5. Finishes the heat and asserts the bridge tears the connection down (status leaves +//! `Connected`). +//! +//! 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, TimerKind, TimerStatus}; +use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; + +/// 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 and drives a live heat through the Director bridge)"] +async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { + // 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, + }), + )]; + // 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(), + }, + }) + .expect("create RH timer"); + // A freshly-configured RH timer rests at `Configured` until the bridge connects it. + assert_eq!( + registry.timers().get(&rh_timer.id).unwrap().status, + TimerStatus::Configured + ); + 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. === + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::from_env(), + AdapterId(SIM_ADAPTER.to_string()), + ); + + // === Drive Practice's heat to Running (what the control path appends). === + let heat = HeatId("q-rh-1".into()); + let pilot = CompetitorRef("Ace".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![pilot.clone()], + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // === The Director connects: status advances to Connected. === + 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 once the socket is up; status = {:?}", + timers.get(&rh_timer.id).map(|t| t.status) + ); + + // === 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); + + // === Finish the heat: the bridge tears the connection down. === + state + .append( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Finished, + }, + None, + ) + .unwrap(); + let torn_down = wait_for(Duration::from_secs(15), || { + timers.get(&rh_timer.id).map(|t| t.status) != Some(TimerStatus::Connected) + }) + .await; + assert!( + torn_down, + "the Director should leave Connected once the heat finishes (teardown); status = {:?}", + timers.get(&rh_timer.id).map(|t| t.status) + ); + + eprintln!( + "app RH-connect e2e: connected, {pass_count} real pass(es) into the event log, then torn down" + ); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7dea902..1e75e5c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -177,6 +177,9 @@ fn live() -> bool { let multi_main_live = target("gridfpv-engine", "multi_main_live", true); // The protocol server's mock-RH e2e: full event → server log → protocol client (#47). let server_e2e = target("gridfpv-server", "full_event_live", true); + // The Director's RH-connect e2e (#65, #73): the per-event bridge connects dockerized RH, + // drives status to Connected, and feeds real passes into the event log. + let rh_connect = target("gridfpv-app", "rh_connect_live", true); ws && live_rh && signal && heat_live @@ -192,6 +195,7 @@ fn live() -> bool { && round_robin_live && multi_main_live && server_e2e + && rh_connect } fn main() { From 54e99168c6fcae2b85da983033fab8c9ed128684 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:14:55 +0000 Subject: [PATCH 071/362] Slice 2b (frontend): live timer connection-status pills in the header (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the active event's timers' connection status in the slim context header so a timer dropping off is visible at a glance from any in-event page. - session: poll `GET /timers` (~2.5s) while inside an event into a reactive `timers` $state; start on selectEvent, clear on leaveEvent/teardown. Add a `selectedTimers` getter that intersects `currentEvent.timers` with the polled registry, in the event's saved order, skipping ids not yet polled. - ContextHeader: render a compact pill row for the active event's selected timers (name + StatusPill), unobtrusive in the slim bar; name labels collapse on narrow screens like the heat label. - StatusPill: map TimerStatus → tone (Connected→live/green + pulse, Connecting→pending/amber, Disconnected→warn, Error→danger/red, Ready/Configured→idle/neutral so a lone Mock reads calm). Add the `danger` tone CSS. Connection (lowercase) and timer (capitalized) vocabularies don't collide, so one map serves both. - TimerManager: overlay the session's live-polled status onto the loaded rows so the in-event Timers screens reflect the same live value as the header, rather than a separate stale fetch. - Tests: session polling + selectedTimers ordering/skip cases; a StatusPill tone test covering the TimerStatus mapping. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../apps/rd-console/src/ContextHeader.svelte | 44 +++++++++- .../apps/rd-console/src/lib/session.svelte.ts | 63 ++++++++++++++ .../src/screens/TimerManager.svelte | 18 +++- .../rd-console/tests/session.svelte.test.ts | 84 +++++++++++++++++++ .../src/primitives/StatusPill.svelte | 34 ++++++-- .../components/tests/StatusPill.test.ts | 39 +++++++++ 6 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 frontend/packages/components/tests/StatusPill.test.ts diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index 9daa054..2e02ece 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -38,6 +38,9 @@ const eventName = $derived(session.currentEvent?.name ?? ''); const live = $derived(session.liveState); const heat = $derived(live?.current_heat); + // The active event's selected timers with their live (polled) connection status (#73, Slice 2b). + // A compact pill per timer keeps "is the timer still connected?" answerable from any in-event page. + const timers = $derived(session.selectedTimers); // Only show a phase once there's a live heat on the timer; otherwise the event is idle. const phase = $derived(heat ? (live?.phase ?? 'Scheduled') : undefined); @@ -77,6 +80,18 @@
    + {#if timers.length} +
    + {#each timers as timer (timer.id)} + + {timer.name} + + + {/each} +
    + + {/if} +
    @@ -180,6 +195,32 @@ font-size: var(--gf-font-size-lg); } + /* ── Timer connection status (#73, Slice 2b) ───────────────────────────────── */ + .ctx-timers { + display: inline-flex; + align-items: center; + gap: var(--gf-space-3); + min-width: 0; + overflow: hidden; + } + .ctx-timer { + display: inline-flex; + align-items: center; + gap: var(--gf-space-2); + min-width: 0; + } + .ctx-timer-name { + font-size: var(--gf-font-size-2xs); + color: var(--gf-text-muted); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + font-weight: var(--gf-font-weight-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 8rem; + } + /* ── Connection + switch ───────────────────────────────────────────────────── */ .ctx-conn { display: inline-flex; @@ -215,7 +256,8 @@ } @media (max-width: 60rem) { - .ctx-heat-label { + .ctx-heat-label, + .ctx-timer-name { display: none; } } diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index cef7f94..9c05d9c 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -70,6 +70,13 @@ import type { const TOKEN_STORAGE_KEY = 'gridfpv.rd.token'; +/** + * How often the session re-reads `GET /timers` for live connection status while inside an event + * (#73, Slice 2b). Polling is the agreed v1 — `GET /timers` returns current status per request — + * and ~2.5s reads "at a glance" without hammering the Director. The interval only runs in-event. + */ +const TIMER_POLL_MS = 2_500; + /** The optional descriptive fields a create-event dialog may supply alongside the name. */ export type CreateEventFields = Omit; @@ -168,6 +175,14 @@ export class Session { heatResult = $state.raw(undefined); /** The last control-path error surfaced to the RD (cleared on the next send). */ lastCommandError = $state(undefined); + /** + * The Director's timer registry with **live** status, polled while inside an event (#73, + * Slice 2b). `GET /timers` returns each timer's *current* status on every request (the + * Director mutates an RH timer's status live as its connection comes and goes), so the + * console polls it (~{@link TIMER_POLL_MS}) so a timer dropping off shows up at a glance. + * `$state.raw`: an immutable list replaced wholesale per poll. Empty until the first poll. + */ + timers = $state.raw([]); // Non-reactive internals. #token: string | undefined; @@ -176,6 +191,8 @@ export class Session { #unsub: (() => void) | undefined; /** The shell's lazy token prompt (a `Dialog`); set via {@link setTokenProvider}. */ #tokenProvider: TokenProvider | undefined; + /** The live-status poll interval while inside an event; cleared on leave/teardown. */ + #timerPoll: ReturnType | undefined; // Injectable for tests so the session never opens a real socket. #connectImpl: typeof connect; #controlFactory: typeof createControlClient; @@ -440,6 +457,50 @@ export class Session { this.connectionStatus = state.status; this.liveState = liveStateOf(state.body); }); + + // Begin polling the timer registry's live status for the header pills (#73, Slice 2b). + this.#startTimerPoll(); + } + + /** + * Poll `GET /timers` while inside an event so the header pills (and the Timers screens, which + * read the same {@link timers}) reflect each timer's **live** connection status (#73, Slice 2b). + * Reads once immediately, then every {@link TIMER_POLL_MS}; a failed poll keeps the last list + * (a transient blip shouldn't blank the pills). {@link selectEvent} starts it; {@link leaveEvent} + * (and a re-select) clears it first, so only one interval ever runs. + */ + #startTimerPoll(): void { + this.#stopTimerPoll(); + const poll = async () => { + try { + this.timers = await this.listTimers(); + } catch { + /* keep the last good list; the next tick retries */ + } + }; + void poll(); + this.#timerPoll = setInterval(poll, TIMER_POLL_MS); + } + + /** Stop the timer-status poll and clear the handle (idempotent). */ + #stopTimerPoll(): void { + if (this.#timerPoll !== undefined) { + clearInterval(this.#timerPoll); + this.#timerPoll = undefined; + } + } + + /** + * The current event's **selected** timers, paired with their live polled status (#73, Slice 2b): + * `currentEvent.timers` (the per-event selection, in its saved order) intersected with the polled + * {@link timers} registry. Drives the context header's status pills. Empty at the picker, or until + * the first poll lands; a selected id not yet in the registry is simply skipped. + */ + get selectedTimers(): Timer[] { + const ids = this.currentEvent?.timers; + if (!ids?.length) return []; + const byId = new Map(this.timers.map((t) => [t.id, t])); + return ids.map((id) => byId.get(id)).filter((t): t is Timer => t !== undefined); } /** Re-scope the live read client within the current event (e.g. to a heat scope). */ @@ -470,6 +531,7 @@ export class Session { * (`PUT /active-event` via {@link chooseEvent}) is what actually re-points the Director. */ leaveEvent(): void { + this.#stopTimerPoll(); this.#unsub?.(); this.#unsub = undefined; this.#client?.close(); @@ -481,6 +543,7 @@ export class Session { this.liveState = undefined; this.heatResult = undefined; this.lastCommandError = undefined; + this.timers = []; } /** diff --git a/frontend/apps/rd-console/src/screens/TimerManager.svelte b/frontend/apps/rd-console/src/screens/TimerManager.svelte index b424936..e892b92 100644 --- a/frontend/apps/rd-console/src/screens/TimerManager.svelte +++ b/frontend/apps/rd-console/src/screens/TimerManager.svelte @@ -89,6 +89,22 @@ void load(); }); + /** + * The rows to render: the loaded registry, but with each timer's **status** overlaid from the + * session's live-polled {@link Session.timers} when available (#73, Slice 2b). The load fetches + * the registry once (and on every CRUD); the in-event poll keeps the *status* fresh, so a timer + * dropping off updates here too — the screen and the header read the same live value. Outside an + * event (the picker's Timers modal) the poll is idle, so this is just the loaded list unchanged. + */ + const displayTimers = $derived.by(() => { + if (loadState.kind !== 'ready') return []; + const liveStatus = new Map(session.timers.map((t) => [t.id, t.status])); + return loadState.timers.map((t) => { + const status = liveStatus.get(t.id); + return status && status !== t.status ? { ...t, status } : t; + }); + }); + /** Reload, then notify the parent so a selection owner can reconcile against the fresh set. */ async function reload() { await load(); @@ -241,7 +257,7 @@ {:else} {@render listHeader?.()}
      - {#each loadState.timers as timer (timer.id)} + {#each displayTimers as timer (timer.id)}
    • {@render rowLead?.(timer)}
      diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 54c2587..13b9fb9 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -555,6 +555,90 @@ describe('Session', () => { expect(setEventTimersImpl).not.toHaveBeenCalled(); }); + // ── Live status polling for the header pills (#73, Slice 2b) ───────────────── + const MOCK_CONNECTED: Timer = { ...MOCK_TIMER, status: 'Connected' }; + + it('polls listTimers while inside an event and exposes the live list', async () => { + vi.useFakeTimers(); + try { + const listTimersImpl = vi.fn(async () => [MOCK_TIMER]); + const session = timerSession({ listTimersImpl }); + session.selectEvent(PRACTICE); + // An immediate poll fires on enter. + await vi.advanceTimersByTimeAsync(0); + expect(listTimersImpl).toHaveBeenCalledTimes(1); + expect(session.timers).toEqual([MOCK_TIMER]); + + // The Director mutates status live; the next poll picks it up. + listTimersImpl.mockResolvedValue([MOCK_CONNECTED]); + await vi.advanceTimersByTimeAsync(2_500); + expect(listTimersImpl).toHaveBeenCalledTimes(2); + expect(session.timers).toEqual([MOCK_CONNECTED]); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('stops polling and clears the list on leaveEvent', async () => { + vi.useFakeTimers(); + try { + const listTimersImpl = vi.fn(async () => [MOCK_TIMER]); + const session = timerSession({ listTimersImpl }); + session.selectEvent(PRACTICE); + await vi.advanceTimersByTimeAsync(0); + expect(listTimersImpl).toHaveBeenCalledTimes(1); + + session.leaveEvent(); + expect(session.timers).toEqual([]); + // No further polls after leaving. + await vi.advanceTimersByTimeAsync(10_000); + expect(listTimersImpl).toHaveBeenCalledTimes(1); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('selectedTimers intersects the event selection with the polled registry, in saved order', async () => { + vi.useFakeTimers(); + try { + const rh: Timer = { + id: 'rh-1', + name: 'Track RH', + kind: { Rotorhazard: { url: 'http://rh' } }, + status: 'Disconnected' + }; + const listTimersImpl = vi.fn(async () => [rh, MOCK_CONNECTED]); // registry order differs + const session = timerSession({ listTimersImpl }); + // Event selects mock then rh-1 — selectedTimers must honor THAT order. + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'] }); + await vi.advanceTimersByTimeAsync(0); + + expect(session.selectedTimers.map((t) => t.id)).toEqual(['mock', 'rh-1']); + expect(session.selectedTimers.map((t) => t.status)).toEqual(['Connected', 'Disconnected']); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('selectedTimers skips a selected id not yet in the registry, and is empty at the picker', async () => { + vi.useFakeTimers(); + try { + const listTimersImpl = vi.fn(async () => [MOCK_TIMER]); // only mock is known + const session = timerSession({ listTimersImpl }); + expect(session.selectedTimers).toEqual([]); // picker: no event + session.selectEvent({ ...PRACTICE, timers: ['mock', 'ghost'] }); + await vi.advanceTimersByTimeAsync(0); + // The unknown id is skipped rather than rendered. + expect(session.selectedTimers.map((t) => t.id)).toEqual(['mock']); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + it('setEventTimers saves and re-homes currentEvent with the server response', async () => { const updated: EventMeta = { ...PRACTICE, timers: ['mock', 'rh-1'] }; const setEventTimersImpl = vi.fn(async () => updated); diff --git a/frontend/packages/components/src/primitives/StatusPill.svelte b/frontend/packages/components/src/primitives/StatusPill.svelte index 292633a..6ba2e90 100644 --- a/frontend/packages/components/src/primitives/StatusPill.svelte +++ b/frontend/packages/components/src/primitives/StatusPill.svelte @@ -1,13 +1,18 @@ @@ -118,6 +133,9 @@ .gf-pill[data-tone='idle'] { --_c: var(--gf-conn-idle); } + .gf-pill[data-tone='danger'] { + --_c: var(--gf-danger); + } .gf-pill-dot { width: 0.5em; diff --git a/frontend/packages/components/tests/StatusPill.test.ts b/frontend/packages/components/tests/StatusPill.test.ts new file mode 100644 index 0000000..568898c --- /dev/null +++ b/frontend/packages/components/tests/StatusPill.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/svelte'; +import StatusPill from '../src/primitives/StatusPill.svelte'; + +/** Read the rendered pill's tone + pulse off the DOM (the color is driven by `data-tone`). */ +function pill(props: Record) { + const { container } = render(StatusPill, props); + const el = container.querySelector('.gf-pill') as HTMLElement; + return { tone: el.getAttribute('data-tone'), pulses: el.classList.contains('pulse'), el }; +} + +describe('StatusPill', () => { + it('maps read-stream connection status to its tone', () => { + expect(pill({ status: 'live' }).tone).toBe('live'); + expect(pill({ status: 'connecting' }).tone).toBe('pending'); + expect(pill({ status: 'reconnecting' }).tone).toBe('warn'); + expect(pill({ status: 'idle' }).tone).toBe('idle'); + }); + + // ── Timer connection status (#73, Slice 2b) ────────────────────────────────── + it('maps a timer TimerStatus to a calm/healthy/alarming tone', () => { + // Resting config reads neutral so a single Mock never alarms. + expect(pill({ status: 'Ready' }).tone).toBe('idle'); + expect(pill({ status: 'Configured' }).tone).toBe('idle'); + // Live healthy connection is green and pulses; dialing is pending. + expect(pill({ status: 'Connecting' }).tone).toBe('pending'); + const connected = pill({ status: 'Connected' }); + expect(connected.tone).toBe('live'); + expect(connected.pulses).toBe(true); + // A dropped/errored timer reads as a problem. + expect(pill({ status: 'Disconnected' }).tone).toBe('warn'); + expect(pill({ status: 'Error' }).tone).toBe('danger'); + }); + + it('phase still maps to its phase tone (no regression)', () => { + expect(pill({ phase: 'Running' }).tone).toBe('running'); + expect(pill({ phase: 'Scheduled' }).tone).toBe('scheduled'); + }); +}); From c57140d56875b7ab850cf589b4b76751068ae7f2 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:15:37 +0000 Subject: [PATCH 072/362] adapters(rh): accept self-signed certs on the RotorHazard timer connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RH timers are LAN devices; one served over HTTPS will almost always present a self-signed cert, which rust_socketio's default TLS would reject. Pass a native_tls connector with danger_accept_invalid_certs/hostnames. Scoped to the timer adapter (LAN trust) — NOT the cloud posture, which must verify TLS. Plain HTTP RH (the common case) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/adapters/src/rotorhazard/transport.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index 207b43a..7dabae6 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -82,7 +82,21 @@ impl RotorHazardConnection { } }; + // 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()) + .tls_config(tls) // 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 From 4719f29be021060bcc90804faeba6fa23d9b6644 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:29:20 +0000 Subject: [PATCH 073/362] components(StatusPill): color the resting timer states (Ready=green, Configured=blue) The pills all read gray because Ready/Configured mapped to the neutral 'idle' tone (--gf-conn-idle = faint text). Give them their own color: Ready (Mock, operational) -> green/success, Configured (set up, awaiting connection) -> blue/info. Connecting/Connected/Disconnected/Error unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../components/src/primitives/StatusPill.svelte | 10 ++++++++-- frontend/packages/components/tests/StatusPill.test.ts | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/packages/components/src/primitives/StatusPill.svelte b/frontend/packages/components/src/primitives/StatusPill.svelte index 6ba2e90..7c5fe77 100644 --- a/frontend/packages/components/src/primitives/StatusPill.svelte +++ b/frontend/packages/components/src/primitives/StatusPill.svelte @@ -47,8 +47,8 @@ // Timer connection status (`TimerStatus`, capitalized — #73/#65). A live, healthy // timer reads green; while dialing it's pending; dropped/errored is danger; the // resting Mock/configured states are calm/neutral so a single Mock never alarms. - Ready: 'idle', - Configured: 'idle', + Ready: 'ready', + Configured: 'configured', Connecting: 'pending', Connected: 'live', Disconnected: 'warn', @@ -133,6 +133,12 @@ .gf-pill[data-tone='idle'] { --_c: var(--gf-conn-idle); } + .gf-pill[data-tone='ready'] { + --_c: var(--gf-success); + } + .gf-pill[data-tone='configured'] { + --_c: var(--gf-info); + } .gf-pill[data-tone='danger'] { --_c: var(--gf-danger); } diff --git a/frontend/packages/components/tests/StatusPill.test.ts b/frontend/packages/components/tests/StatusPill.test.ts index 568898c..f407f68 100644 --- a/frontend/packages/components/tests/StatusPill.test.ts +++ b/frontend/packages/components/tests/StatusPill.test.ts @@ -19,9 +19,10 @@ describe('StatusPill', () => { // ── Timer connection status (#73, Slice 2b) ────────────────────────────────── it('maps a timer TimerStatus to a calm/healthy/alarming tone', () => { - // Resting config reads neutral so a single Mock never alarms. - expect(pill({ status: 'Ready' }).tone).toBe('idle'); - expect(pill({ status: 'Configured' }).tone).toBe('idle'); + // Resting states carry their own color (not gray): a ready Mock reads healthy + // (green), a configured-but-unconnected timer reads informational (blue). + expect(pill({ status: 'Ready' }).tone).toBe('ready'); + expect(pill({ status: 'Configured' }).tone).toBe('configured'); // Live healthy connection is green and pulses; dialing is pending. expect(pill({ status: 'Connecting' }).tone).toBe('pending'); const connected = pill({ status: 'Connected' }); From 86046ea393d7850299f7ce6eaa8258cfb0fd90be Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:33:01 +0000 Subject: [PATCH 074/362] RH timer connects persistently on selection; races use the live connection (#105) Previously a RotorHazard timer connected only while a heat was Running and tore the socket down when the heat left Running, so a selected-but-idle RH timer read Configured and a drop-off could not surface before/between races. Decouple connecting from race-driving: - New persistent connection layer (live-gated): RhConnection runs one spawn_blocking driver per (active event, RH timer): connect -> Connecting/ Connected -> maintain+monitor the link -> on a drop set Disconnected/Error and reconnect with backoff -> on cancel stop race + disconnect + leave Disconnected. An idle link is probed for liveness so a quiet-but-healthy connection stays Connected and a dropped one surfaces. - RhConnections + spawn_rh_reconciler reconcile the live set against the active event's selected RH timers (open new, close deselected / on active event change). - The per-event bridge now ARMS a running heat onto the already-live connection (stage + drain remapped passes into the event log) and DISARMS it when the heat leaves Running -- the connection stays alive. Mock timers keep their per-heat SimSource behavior unchanged; a non-live build keeps the RH no-op stub. - Add RotorHazardConnection::probe_liveness for the idle monitor. - Update the dockerized live test: assert Connected ON SELECTION before any heat, passes flow over the live connection during a heat, and the connection STAYS Connected after the heat finishes. Default cargo xtask ci stays green and OpenSSL-free (live is excluded from the core suite); live build + clippy clean; rh_connect_live passes on Docker. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/adapters/src/rotorhazard/transport.rs | 9 + crates/app/src/source.rs | 179 ++++++-- crates/app/src/source/rh_connections.rs | 159 +++++++ crates/app/src/source/rotorhazard.rs | 446 +++++++++++-------- crates/app/tests/rh_connect_live.rs | 93 ++-- 5 files changed, 645 insertions(+), 241 deletions(-) create mode 100644 crates/app/src/source/rh_connections.rs diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index 7dabae6..b5cfabb 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -156,6 +156,15 @@ impl RotorHazardConnection { self.client.emit("discard_laps", Payload::Text(vec![])) } + /// 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. pub fn disconnect(self) -> Result<(), rust_socketio::Error> { self.client.disconnect() diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 51ff303..4ec5c62 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -58,10 +58,12 @@ use gridfpv_server::timers::{TimerKind, TimerRegistry}; use gridfpv_storage::Offset; use tokio::task::JoinHandle; +#[cfg(feature = "live")] +mod rh_connections; #[cfg(feature = "live")] mod rotorhazard; #[cfg(feature = "live")] -pub use rotorhazard::RhSource; +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 @@ -343,6 +345,17 @@ pub fn spawn_registry_bridge( _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. @@ -359,8 +372,19 @@ pub fn spawn_registry_bridge( 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).await; + run_bridge( + state, + registry, + timers, + event_id, + adapter, + #[cfg(feature = "live")] + connections, + ) + .await; }); attached.insert(meta.id); } @@ -384,6 +408,7 @@ pub(crate) async fn run_bridge( timers: TimerRegistry, event_id: EventId, adapter: AdapterId, + #[cfg(feature = "live")] connections: RhConnections, ) { let mut cursor: Offset = 0; // The in-flight heat task, if a heat is currently emitting. At most one at a time. @@ -394,9 +419,21 @@ pub(crate) async fn run_bridge( ticker.tick().await; // 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). + // 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 { - if running.is_finished() { + 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; } } @@ -423,6 +460,8 @@ pub(crate) async fn run_bridge( &event_id, &adapter, &mut active, + #[cfg(feature = "live")] + &connections, heat, transition, ); @@ -431,17 +470,17 @@ pub(crate) async fn run_bridge( } } -/// Resolve the event's selected timers into the [`LapSource`]s to run for this heat (issues #73, -/// #65). +/// 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 maps to a live [`RhSource`] (under the `live` -/// feature) that connects to its `url`, drives the race, feeds RH passes into the event log, and -/// updates that timer's connection [`TimerStatus`](gridfpv_server::timers::TimerStatus) as it goes; -/// in a **non-`live`** build a RotorHazard timer is a no-op stub (skipped). An id that no longer -/// resolves (a since-deleted timer) is skipped too. Returns the sources to drive concurrently for -/// the running heat — empty when the event selects no usable timer. +/// `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, @@ -462,17 +501,36 @@ fn selected_sources( Duration::from_millis(lap_ms), ))); } - // A live build connects the real RotorHazard; the default build keeps it a no-op stub. - TimerKind::Rotorhazard { url } => { - let _ = (&id, &url); - #[cfg(feature = "live")] - sources.push(Arc::new(RhSource::new(id, url, timers.clone()))); - } + // 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)] @@ -483,6 +541,7 @@ fn handle_transition( event_id: &EventId, adapter: &AdapterId, active: &mut Option, + #[cfg(feature = "live")] connections: &RhConnections, heat: HeatId, transition: HeatTransition, ) { @@ -491,7 +550,12 @@ fn handle_transition( // 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. if let Some(running) = active.take() { - running.abort(); + 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. @@ -500,8 +564,7 @@ fn handle_transition( if lineup.is_empty() { return; } - // Resolve the event's selected timers to the source(s) to run. With no usable timer - // selected (e.g. only a RotorHazard stub, or nothing) the heat emits nothing. + // Resolve the event's selected Mock timers to the synthetic source(s) to run. let sources = selected_sources(registry, timers, event_id); let mut handles = Vec::with_capacity(sources.len()); for source in sources { @@ -516,10 +579,33 @@ fn handle_transition( } })); } - if handles.is_empty() { + // 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. + #[cfg(feature = "live")] + let armed_rh = { + let mut armed = Vec::new(); + for timer_id in selected_rh_timers(registry, timers, event_id) { + let sink = PassSink::new(state.clone(), adapter.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; + if handles.is_empty() && nothing_armed { return; } - *active = Some(ActiveHeat { heat, handles }); + *active = Some(ActiveHeat { + heat, + handles, + #[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). @@ -532,7 +618,12 @@ fn handle_transition( if let Some(running) = active.as_ref() { if running.heat == heat { if let Some(running) = active.take() { - running.abort(); + running.stop( + #[cfg(feature = "live")] + connections, + #[cfg(feature = "live")] + event_id, + ); } } } @@ -542,24 +633,41 @@ fn handle_transition( } } -/// A heat currently emitting passes: which heat, and the task(s) driving its selected timer -/// source(s) (issue #73 — an event may select several timers running concurrently). +/// 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 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 source task has finished — the slot can be reaped. + /// 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()) } - /// Cancel every in-flight source task (a heat going off `Running`, or superseded). - fn abort(&self) { + /// 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); + } } } @@ -642,8 +750,19 @@ mod tests { 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).await + run_bridge( + bridge_state, + reg, + timers, + practice(), + adapter, + #[cfg(feature = "live")] + connections, + ) + .await }); (handle, state) } diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs new file mode 100644 index 0000000..262c4be --- /dev/null +++ b/crates/app/src/source/rh_connections.rs @@ -0,0 +1,159 @@ +//! 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 + } + } + + /// 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(); + for key in stale { + if let Some(conn) = map.remove(&key) { + // 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 index b9b2c2d..b240ec4 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -1,241 +1,333 @@ -//! The live **RotorHazard lap source** (#65, Slice 2b) — only compiled under `--features live`. +//! The live **RotorHazard connection layer** (#65, #105) — only compiled under `--features live`. //! -//! This is the real counterpart to the [`SimSource`](super::SimSource): when an event selects a -//! [`Rotorhazard { url }`](gridfpv_server::timers::TimerKind::Rotorhazard) timer and a heat goes -//! `Running`, the per-event bridge ([`run_bridge`](super::run_bridge)) drives an [`RhSource`] for -//! it, exactly as it drives a `SimSource` for a Mock timer. The `RhSource`: +//! 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. //! -//! 1. **Connects** to the RH server at `url` through the adapters crate's feature-gated -//! [`RotorHazardConnection`] (a Socket.IO transport behind the pure -//! [`RotorHazardAdapter`](gridfpv_adapters::rotorhazard::RotorHazardAdapter) translator). -//! 2. **Drives the race**: resets RH to a clean state, stages it, then polls the connection's -//! translated [`Event`] stream and **feeds the passes into the event's log** (the same log the -//! Mock bridge appends to, so `/stream` wakes and the console animates). RH node seats -//! (`node-0`, `node-1`, …) are remapped onto the running heat's lineup by index, so passes -//! attribute to the heat's actual competitors. -//! 3. **Tears down** on cancellation — when the heat leaves `Running` the bridge drops this -//! future; a cancel flag tells the driver thread to stop the RH race and disconnect. +//! This module splits that responsibility into two pieces: //! -//! Throughout, it updates the timer's **live connection status** in the -//! [`TimerRegistry`](gridfpv_server::timers::TimerRegistry): -//! [`Connecting`](gridfpv_server::timers::TimerStatus::Connecting) → -//! [`Connected`](gridfpv_server::timers::TimerStatus::Connected) → -//! [`Disconnected`](gridfpv_server::timers::TimerStatus::Disconnected) (clean teardown) or -//! [`Error`](gridfpv_server::timers::TimerStatus::Error) (the connect failed). A fresh `GET /timers` -//! reflects the current value, so the console can poll a drop-off. +//! 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*. When a heat enters `Running` the bridge +//! [arms](RhConnection::arm_heat) the heat on each selected RH connection (stage the RH race + +//! remap 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 → reset → stage → drain → stop → disconnect) therefore runs on one dedicated -//! [`spawn_blocking`](tokio::task::spawn_blocking) thread; the async `run_heat` future only awaits -//! it and flips a shared **cancel flag** when the bridge drops it. The driver checks that flag each -//! loop and tears down cleanly on its own thread, so an aborted future never emits on the runtime. +//! (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::pin::Pin; -use std::sync::Arc; 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::RotorHazardConnection; use gridfpv_events::{AdapterId, CompetitorRef, Event}; use gridfpv_server::timers::{TimerId, TimerRegistry, TimerStatus}; +use tokio::task::JoinHandle; -use super::{HeatRun, LapSource, PassSink, SourceError}; +use super::PassSink; -/// How often the driver thread drains the RotorHazard connection's translated-event queue and -/// appends any new passes to the log. +/// 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, 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). +/// 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); -/// A live RotorHazard lap source (feature `live`): connects to one RH server and feeds its passes -/// into the running heat's log, maintaining the timer's connection status. See the [module -/// docs](self). -pub struct RhSource { - /// The selected timer's id — the handle whose [`TimerStatus`] this source drives. - timer_id: TimerId, - /// The RH server base URL to dial (e.g. `http://rotorhazard.local:5000`). - url: String, - /// The app-level timer registry, so the source can publish its live connection status. - timers: TimerRegistry, +/// 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); + +/// 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, } -impl RhSource { - /// A live RH source for `timer_id` pointed at `url`, publishing status through `timers`. - pub fn new(timer_id: TimerId, url: String, timers: TimerRegistry) -> Self { +/// 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, + /// The armed-heat slot: `Some` while a heat is racing on this connection, else `None`. + armed: Arc>>, + /// 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 armed: Arc>> = Arc::new(Mutex::new(None)); + let driver = { + let cancel = cancel.clone(); + let armed = armed.clone(); + tokio::task::spawn_blocking(move || { + drive(url, timer_id, timers, cancel, armed); + }) + }; Self { - timer_id, - url, - timers, + cancel, + armed, + _driver: driver, } } - /// 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() + /// Arm a running heat onto this live connection: the driver stages the RH race and 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, + }); } - /// Remap one canonical RH [`Event`] onto the heat's lineup and this source's adapter id, or - /// `None` to drop it. Only [`Event::Pass`]es feed the lap projection here; a pass on node `n` - /// is attributed to `lineup[n]` (its actual competitor) and re-stamped with `adapter`. Passes - /// for a node outside the lineup (an idle seat) are dropped. The adapter's lifecycle / - /// `CompetitorSeen` events are not appended — the heat's lineup is already established by the - /// control path, and the bridge owns the heat lifecycle. - fn remap(event: Event, lineup: &[CompetitorRef], adapter: &AdapterId) -> Option { - match event { - Event::Pass(mut pass) => { - let index = Self::node_index(&pass.competitor)?; - let competitor = lineup.get(index)?.clone(); - pass.adapter = adapter.clone(); - pass.competitor = competitor; - Some(Event::Pass(pass)) - } - _ => None, + /// Disarm the current heat (it left `Running`): the driver stops/clears the RH race but the + /// **connection stays alive** (and keeps reporting status). A no-op if nothing is armed. + pub fn disarm(&self) { + let mut slot = self.armed.lock().expect("armed-heat lock poisoned"); + *slot = None; + } + + /// 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); + } +} + +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. Only [`Event::Pass`]es feed the lap projection; a pass on node `n` is attributed to +/// `lineup[n]` and re-stamped with `adapter`. Passes 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)) } + _ => None, } +} - /// The blocking driver: the whole connection lifecycle on one dedicated thread (see the module - /// docs on why this must not run on a tokio worker). Runs until `cancel` is set, then tears - /// the connection down and marks the timer `Disconnected`. Returns the last error, if any. - fn drive( - url: String, - timer_id: TimerId, - timers: TimerRegistry, - sink: PassSink, - lineup: Vec, - cancel: Arc, - ) -> Result<(), SourceError> { +/// The persistent driver: connect → `Connected` → maintain/monitor → reconnect on drop, until +/// cancelled, then disconnect and leave `Disconnected` (#105). Runs on a dedicated blocking thread. +fn drive( + url: String, + timer_id: TimerId, + timers: TimerRegistry, + cancel: Arc, + armed: Arc>>, +) { + let mut backoff = RECONNECT_BACKOFF_MIN; + while !cancel.load(Ordering::Relaxed) { timers.set_status(&timer_id, TimerStatus::Connecting); - let conn = match RotorHazardConnection::connect(&url, RotorHazardAdapter::new()) { Ok(conn) => conn, Err(e) => { + // The connect attempt failed: surface Error, back off, and retry (unless cancelled). + eprintln!( + "gridfpv: RotorHazard connect failed for {:?}: {e}", + timer_id.0 + ); timers.set_status(&timer_id, TimerStatus::Error); - return Err(SourceError(format!("RotorHazard connect failed: {e}"))); + 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; - // From here the connection is up: whatever happens, leave the timer Disconnected and stop - // the RH race on the way out. - let result = Self::drive_connected(&conn, &sink, &lineup, &cancel); + // Maintain the live link until it drops or we are cancelled. + let dropped = maintain(&conn, &cancel, &armed); + // Stop any in-flight race and disconnect cleanly on the way out of this connection. conn.stop_race().ok(); conn.disconnect().ok(); - timers.set_status(&timer_id, TimerStatus::Disconnected); - result - } - /// The connected phase: reset → stage → drain passes into the log until cancelled. - fn drive_connected( - conn: &RotorHazardConnection, - sink: &PassSink, - lineup: &[CompetitorRef], - cancel: &AtomicBool, - ) -> Result<(), SourceError> { - // Reset RH to a clean READY state (a prior DONE race blocks staging), drop the reset-era - // event churn, then stage (RH auto-starts). - conn.stop_race().ok(); - conn.discard_laps().ok(); - Self::sleep_unless_cancelled(Duration::from_millis(400), cancel); - let _ = conn.events(); if cancel.load(Ordering::Relaxed) { - return Ok(()); + break; } - let _ = conn.stage_race(); - - // Wait (bounded) for RACING so the first passes are real race passes, but keep draining - // throughout so nothing is dropped while we wait. - let adapter = sink.adapter().clone(); - let deadline = Instant::now() + STAGE_SETTLE; - let mut started = false; - loop { - if cancel.load(Ordering::Relaxed) { - return Ok(()); - } - for event in conn.events() { - if matches!(event, Event::SessionStarted { .. }) { - started = true; - } - if let Some(remapped) = Self::remap(event, lineup, &adapter) { - sink.append_event(remapped)?; - } - } - if started || Instant::now() >= deadline { + // 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; } - Self::sleep_unless_cancelled(DRAIN_INTERVAL, cancel); + backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); } + } + // Cancelled: leave the timer Disconnected (deselected / event changed / shutdown). + timers.set_status(&timer_id, TimerStatus::Disconnected); +} + +/// 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>, +) -> bool { + let mut last_activity = Instant::now(); + let mut probed_since_activity = false; + let mut stage_deadline: Option = None; - // Steady-state drain until the bridge cancels us (the heat left Running). - while !cancel.load(Ordering::Relaxed) { - for event in conn.events() { - if let Some(remapped) = Self::remap(event, lineup, &adapter) { - sink.append_event(remapped)?; + while !cancel.load(Ordering::Relaxed) { + // Stage a freshly-armed heat once (reset RH to a clean READY state, then stage; RH + // auto-starts). Done lazily here so staging happens on the driver thread, not the caller. + let mut just_staged = false; + { + let mut slot = armed.lock().expect("armed-heat lock poisoned"); + if let Some(heat) = slot.as_mut() { + if !heat.staged { + conn.stop_race().ok(); + conn.discard_laps().ok(); + // Drop the reset-era event churn 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; + } + heat.staged = true; + just_staged = true; + stage_deadline = Some(Instant::now() + STAGE_SETTLE); } + } else { + // Nothing armed: clear any stale stage wait. + stage_deadline = None; } - Self::sleep_unless_cancelled(DRAIN_INTERVAL, cancel); } - Ok(()) - } + if just_staged { + last_activity = Instant::now(); + probed_since_activity = false; + } - /// Sleep `dur`, but wake early (in short slices) if `cancel` flips, so teardown is prompt. - fn sleep_unless_cancelled(dur: Duration, cancel: &AtomicBool) { - let deadline = Instant::now() + dur; - while Instant::now() < deadline { - if cancel.load(Ordering::Relaxed) { - return; + // 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; + } + 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; + } } - std::thread::sleep(Duration::from_millis(20)); + // 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; } - } -} -impl LapSource for RhSource { - fn run_heat( - &self, - run: HeatRun, - sink: PassSink, - ) -> Pin> + Send>> { - let timer_id = self.timer_id.clone(); - let url = self.url.clone(); - let timers = self.timers.clone(); - Box::pin(async move { - // A shared cancel flag: the driver runs on a blocking thread; this future flips the - // flag when it is dropped (the bridge aborts it on the heat leaving Running) so the - // driver tears down on its own thread — never emitting on a tokio worker. - let cancel = Arc::new(AtomicBool::new(false)); - let _guard = CancelOnDrop(cancel.clone()); - - let lineup = run.lineup.clone(); - let driver = tokio::task::spawn_blocking(move || { - Self::drive(url, timer_id, timers, sink, lineup, cancel) - }); - - // Await the driver. If THIS future is dropped (cancelled), `_guard` flips the flag and - // the detached driver thread tears down cleanly; we simply never observe its result. - match driver.await { - Ok(result) => result, - Err(join) => Err(SourceError(format!( - "RotorHazard driver task failed: {join}" - ))), - } - }) + if sleep_unless_cancelled(DRAIN_INTERVAL, cancel) { + return false; + } } + false } -/// Flips a cancel flag when dropped — the async future's hook to stop the blocking driver thread -/// on cancellation (the bridge aborting the heat task) without emitting on the runtime. -struct CancelOnDrop(Arc); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - self.0.store(true, Ordering::Relaxed); +/// 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) } diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 2892ad9..13ae2a4 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -1,20 +1,23 @@ -//! Dockerized-RotorHazard **Director connect** e2e (#65, #73 — Slice 2b backend). +//! Dockerized-RotorHazard **Director persistent-connect** e2e (#65, #73, #105). //! -//! The end-to-end proof that the Director's per-event source bridge *actually connects* a real -//! RotorHazard and feeds its passes into the event's log — the live counterpart to the in-process -//! Mock-bridge tests in `src/source.rs`. It: +//! 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, and **selects it for Practice** (the per-event selection the bridge reads). +//! 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 — then drives Practice's heat to `Running` through its log (what the control path -//! appends). -//! 4. Asserts the Director **connects**: the timer's [`TimerStatus`] advances to `Connected`, and -//! **passes flow** into Practice's log (real RH crossings, attributed to the heat's lineup). -//! 5. Finishes the heat and asserts the bridge tears the connection down (status leaves -//! `Connected`). +//! 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 to `Running`, and asserts **passes flow** into Practice's log over +//! that already-live connection (real RH crossings, attributed to the heat's lineup). +//! 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. //! //! 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 @@ -82,8 +85,8 @@ async fn wait_for(deadline: Duration, mut cond: impl FnMut() -> bool) -> bool { } #[tokio::test(flavor = "multi_thread")] -#[ignore = "requires Docker (spins up dockerized RotorHazard and drives a live heat through the Director bridge)"] -async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { +#[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![( @@ -108,25 +111,53 @@ async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { }, }) .expect("create RH timer"); - // A freshly-configured RH timer rests at `Configured` until the bridge connects it. + // 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. === + // === 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()), ); - // === Drive Practice's heat to Running (what the control path appends). === + // === 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) + ); + // 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 to Running (what the control path appends). It uses the + // ALREADY-LIVE connection rather than dialing a fresh socket. === let heat = HeatId("q-rh-1".into()); let pilot = CompetitorRef("Ace".into()); state @@ -148,19 +179,6 @@ async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { ) .unwrap(); - // === The Director connects: status advances to Connected. === - 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 once the socket is up; status = {:?}", - timers.get(&rh_timer.id).map(|t| t.status) - ); - // === 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 @@ -181,7 +199,7 @@ async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { ); let pass_count = count_passes(&events); - // === Finish the heat: the bridge tears the connection down. === + // === Finish the heat: the heat is disarmed but the persistent connection STAYS UP (#105). === state .append( Event::HeatStateChanged { @@ -191,17 +209,24 @@ async fn director_connects_rotorhazard_and_passes_flow_into_the_event_log() { None, ) .unwrap(); - let torn_down = wait_for(Duration::from_secs(15), || { + // 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!( - torn_down, - "the Director should leave Connected once the heat finishes (teardown); status = {:?}", + !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-connect e2e: connected, {pass_count} real pass(es) into the event log, then torn down" + "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" ); } From 579d59156fe9c89e4b1773fe0fb27f8b13578e64 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:41:40 +0000 Subject: [PATCH 075/362] =?UTF-8?q?docs(clients):=20note=20field=20readabi?= =?UTF-8?q?lity=20=E2=80=94=20large=20text=20+=20dark=20mode=20for=20sunli?= =?UTF-8?q?t=20laptop=20race-ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RD console runs outdoors in sun on medium laptops; record larger base text + dark-mode default as a design gate. Base-size bump tracked as #108. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/clients.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/clients.html b/docs/clients.html index 90c7c6a..948516f 100644 --- a/docs/clients.html +++ b/docs/clients.html @@ -105,6 +105,17 @@

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

      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): once the Tauri shell From a3b9bf4ffc47d30cb2f57b9ef5334fda3e07e566 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 00:46:00 +0000 Subject: [PATCH 076/362] fix: detect RotorHazard connection drops (reconnect(false) + close handler) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dropped/stopped RotorHazard timer kept reading `Connected`: the persistent driver's liveness monitor never fired. `probe_liveness` emits `load_data`, but the `rust_socketio` client was built with `.reconnect(true)`, so on a dropped connection it buffers the emit and returns `Ok` (auto-reconnecting internally) — the emit never errors, so the driver (which only treated an emit `Err` as a drop) never noticed. Verified live: `docker stop` the RH → the timer stayed `Connected` for 24s. Fix: make a real drop deterministically detectable and let the driver own reconnection (it already has backoff) instead of `rust_socketio`'s hidden auto-reconnect fighting it. - transport: build the client with `.reconnect(false)` so a drop is a real, final close. Register handlers on `rust_socketio`'s reserved `close`/`error` events that flip a shared `alive: Arc` to `false`, and expose `pub fn is_alive(&self)`. On a dropped socket the poll loop fires `error` (engine.io read failed) — the truth the driver reads. - driver (source/rotorhazard): the monitor checks `conn.is_alive()` at the top of each maintain tick; a drop → return, driver sets `Disconnected` and reconnects via the existing backoff (re-warms `load_data`; per-lap dedup keeps the re-sent `current_laps` snapshot safe). `probe_liveness`/idle warm kept. - testkit: add `RhContainer::name()` + `stop()` so a test can `docker stop` the container; RAII `Drop` still removes it. - live test: after `Connected`, stop the RH container and assert the timer leaves `Connected` (→ Disconnected/Error/Connecting) within ~10s. Live proof (cargo test --features live): the stopped RH was detected and the timer transitioned to `Disconnected` (driver logged "connection lost; reconnecting") — the drop is now caught. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/adapters/src/rotorhazard/transport.rs | 48 +++++++++++++++++--- crates/app/src/source/rotorhazard.rs | 7 +++ crates/app/tests/rh_connect_live.rs | 33 ++++++++++++++ crates/testkit/src/lib.rs | 15 ++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index b5cfabb..8384224 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -17,6 +17,7 @@ // 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 rust_socketio::client::Client; @@ -59,6 +60,11 @@ pub fn raw_from_socket(event: &str, payload: &Payload) -> Option { pub struct RotorHazardConnection { client: Client, events: 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, } impl RotorHazardConnection { @@ -67,6 +73,17 @@ 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)); + + // `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. let handler = |name: &'static str, @@ -97,11 +114,18 @@ impl RotorHazardConnection { let client = ClientBuilder::new(url.to_string()) .tls_config(tls) - // 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) + // 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 re-warms state). On its 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(false) + .on("error", drop_handler(alive.clone())) + .on("close", drop_handler(alive.clone())) .on( "race_status", handler("race_status", adapter.clone(), events.clone()), @@ -125,7 +149,19 @@ impl RotorHazardConnection { // and `race_status` arrive via the normal snapshot stream. let _ = client.emit("load_data", json!({ "load_types": ["node_data"] })); - Ok(Self { client, events }) + Ok(Self { + client, + events, + alive, + }) + } + + /// 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. diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index b240ec4..815c309 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -247,6 +247,13 @@ fn maintain( let mut stage_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; + } + // Stage a freshly-armed heat once (reset RH to a clean READY state, then stage; RH // auto-starts). Done lazily here so staging happens on the driver thread, not the caller. let mut just_staged = false; diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 13ae2a4..b195f72 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -18,6 +18,10 @@ //! that already-live connection (real RH crossings, attributed to the heat's lineup). //! 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 @@ -229,4 +233,33 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu "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() + ); } diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 811aa79..4811c45 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -498,6 +498,21 @@ 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 + } + + /// 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 { From fb6bfcd730b42588a1172a4a59120c426bc3fef0 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 01:16:53 +0000 Subject: [PATCH 077/362] Timers: primary/alternate roles + failover (single-source feed) (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redundant timers at one gate: among an event's selected timers one is the Primary, the rest Alternates. The per-event source bridge now feeds ONLY the active source's passes into the log — fixing the double-count when more than one timer is selected — and fails over live. Role model (additive): - EventMeta.primary_timer: Option (#[serde(default)]); when unset the first selected timer is the effective primary (EventMeta::effective_primary). - API: PUT /events/{id}/timers now accepts an optional `primary` (must be in the selection); new PUT /events/{id}/primary-timer designates/clears it. Both RD-gated; an out-of-selection primary is a 400. set_timers drops a stale primary. Bindings regenerated (EventMeta, SetEventTimersRequest, new SetPrimaryTimerRequest). Single-active-source feed + failover (the core): - source/failover.rs: a pure active_source(meta, timers) — the primary while healthy, else the first healthy alternate (selection order), else none; primary-preferred (recovery switches back). A Mock is always healthy; an RH timer is healthy only while Connected. - An ActiveSourceGate seeded on a Running heat and re-evaluated every bridge poll drives a per-source PassSink gate: each Mock task / armed RH connection stays live (hot standby) but only the active source's appends land. Mock-only and single-timer events behave exactly as before (one timer = primary = only feed). Tests: failover unit tests (primary feeds; drop → alternate; recovery → primary; two healthy timers never double-count; stale primary falls back) + an in-process bridge failover test; a dockerized RH-primary + Mock-alternate live e2e (rh_connect_live.rs, wired into `cargo xtask live`) that stops the RH container mid-heat and asserts the Mock alternate takes over. Frontend: events contract + fixtures updated for the additive primary_timer. UI is a follow-up — this slice is backend + bindings + contract only. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/EventMeta.ts | 15 +- bindings/SetEventTimersRequest.ts | 11 +- bindings/SetPrimaryTimerRequest.ts | 13 + crates/app/src/source.rs | 299 +++++++++++++++++++++-- crates/app/src/source/failover.rs | 175 +++++++++++++ crates/app/tests/rh_connect_live.rs | 149 ++++++++++- crates/server/src/app.rs | 62 ++++- crates/server/src/events.rs | 76 ++++++ crates/server/src/timers.rs | 21 +- frontend/contract/events.contract.ts | 116 +++++++++ frontend/packages/types/src/generated.ts | 1 + 11 files changed, 913 insertions(+), 25 deletions(-) create mode 100644 bindings/SetPrimaryTimerRequest.ts create mode 100644 crates/app/src/source/failover.rs diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts index 523271d..31d3a8f 100644 --- a/bindings/EventMeta.ts +++ b/bindings/EventMeta.ts @@ -57,4 +57,17 @@ organizer?: string, * box. The per-event source bridge runs the selected Sim timers; a selected RotorHazard is a * reserved no-op stub (2b / #65). */ -timers: Array, }; +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, }; diff --git a/bindings/SetEventTimersRequest.ts b/bindings/SetEventTimersRequest.ts index dafad97..042323e 100644 --- a/bindings/SetEventTimersRequest.ts +++ b/bindings/SetEventTimersRequest.ts @@ -2,10 +2,17 @@ import type { TimerId } from "./TimerId"; /** - * The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73). + * 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, }; +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/crates/app/src/source.rs b/crates/app/src/source.rs index 4ec5c62..326b46c 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -45,7 +45,7 @@ //! driving one timer. use std::collections::HashSet; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::time::Duration; use gridfpv_events::{ @@ -54,10 +54,13 @@ use gridfpv_events::{ use gridfpv_server::app::AppState; use gridfpv_server::events::EventRegistry; use gridfpv_server::scope::EventId; -use gridfpv_server::timers::{TimerKind, TimerRegistry}; +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")] @@ -94,18 +97,93 @@ pub struct HeatRun { 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, } impl PassSink { - /// A sink over `state` tagging passes with `adapter`. + /// 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 } + Self { + state, + adapter, + gate: None, + timer: 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), + } + } + + /// 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 @@ -117,6 +195,11 @@ impl PassSink { 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 = Event::Pass(Pass { adapter: self.adapter.clone(), competitor: competitor.clone(), @@ -138,6 +221,11 @@ impl PassSink { /// 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(()); + } self.state .append(event, None) .map_err(|e| SourceError(format!("{e:?}")))?; @@ -438,6 +526,17 @@ pub(crate) async fn run_bridge( } } + // 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 poisoned lock (or a dropped log at shutdown) ends the bridge cleanly. @@ -485,21 +584,21 @@ fn selected_sources( registry: &EventRegistry, timers: &TimerRegistry, event_id: &EventId, -) -> Vec> { +) -> Vec<(TimerId, Arc)> { let Some(selection) = registry.timers_of(event_id) else { return Vec::new(); }; - let mut sources: Vec> = 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(Arc::new(SimSource::new( - laps, - Duration::from_millis(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 { .. } => {} @@ -564,11 +663,21 @@ fn handle_transition( if lineup.is_empty() { return; } - // Resolve the event's selected Mock timers to the synthetic source(s) to run. + // 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 source in sources { - let sink = PassSink::new(state.clone(), adapter.clone()); + for (timer_id, source) in sources { + let sink = PassSink::gated(state.clone(), adapter.clone(), gate.clone(), timer_id); let run = HeatRun { heat: heat.clone(), lineup: lineup.clone(), @@ -581,12 +690,19 @@ fn handle_transition( } // 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. + // 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 sink = PassSink::new(state.clone(), adapter.clone()); + let sink = PassSink::gated( + state.clone(), + adapter.clone(), + gate.clone(), + timer_id.clone(), + ); if connections.arm_heat(event_id, &timer_id, lineup.clone(), sink) { armed.push(timer_id); } @@ -603,6 +719,7 @@ fn handle_transition( *active = Some(ActiveHeat { heat, handles, + gate, #[cfg(feature = "live")] armed_rh, }); @@ -639,6 +756,10 @@ fn handle_transition( 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")] @@ -713,7 +834,9 @@ mod tests { use gridfpv_server::events::{EventRegistry, PRACTICE_EVENT_ID}; use gridfpv_server::live_state::live_state; - use gridfpv_server::timers::{MOCK_TIMER_ID, TimerId, TimerKind, UpdateTimerRequest}; + 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"]` @@ -1099,4 +1222,148 @@ mod tests { 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 }, + }) + .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, + }, + 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(), + }, + }) + .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..95f2025 --- /dev/null +++ b/crates/app/src/source/failover.rs @@ -0,0 +1,175 @@ +//! **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(), + }, + }) + .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/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index b195f72..3db6e66 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -42,7 +42,7 @@ 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, TimerKind, TimerStatus}; +use gridfpv_server::timers::{CreateTimerRequest, MOCK_TIMER_ID, TimerId, TimerKind, TimerStatus}; use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; /// DISTINCT RH host port for the app's RH-connect e2e (server full-event 5041, engine 5040). @@ -263,3 +263,150 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu 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, + }), + )]; + 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, + }), + }, + ) + .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(), + }, + }) + .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: while the RH primary is healthy, its passes feed (the Mock alternate is gated). + let heat = HeatId("q-fo-1".into()); + let pilot = CompetitorRef("Ace".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![pilot.clone()], + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + 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/server/src/app.rs b/crates/server/src/app.rs index 7d816db..6c1c99e 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -97,7 +97,8 @@ use crate::scope::{ClassId, EventId, PilotId}; use crate::snapshot::{ProjectionBody, Snapshot}; use crate::stream::Cursor; use crate::timers::{ - CreateTimerRequest, SetEventTimersRequest, Timer, TimerId, UpdateTimerRequest, + CreateTimerRequest, SetEventTimersRequest, SetPrimaryTimerRequest, Timer, TimerId, + UpdateTimerRequest, }; /// The object-safe slice of [`EventLog`] the protocol transport needs: read the whole @@ -299,6 +300,11 @@ pub fn router(registry: EventRegistry) -> Router { .route("/timers/{timer_id}", put(update_timer).delete(delete_timer)) // 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}", @@ -466,11 +472,13 @@ async fn delete_timer( Ok(StatusCode::OK) } -/// `PUT /events/{event_id}/timers` — set an event's **selected timers**, RD-gated (issue #73). +/// `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. On success the updated [`EventMeta`] is returned. +/// 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, @@ -487,9 +495,53 @@ async fn set_event_timers( )); } } - let meta = registry + // 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(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; + // 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(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; + 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(|e| { + // "not in the selection" is a bad request; an unknown event is a 404. + let code = if e.0.contains("not in the event's selected timers") { + ErrorCode::BadRequest + } else { + ErrorCode::UnknownScope + }; + ProtocolError::new(code, e.to_string()) + })?; Ok(Json(meta)) } @@ -1583,6 +1635,7 @@ mod tests { // 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( @@ -1603,6 +1656,7 @@ mod tests { // 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( diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 3703cf6..226c06e 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -104,6 +104,36 @@ pub struct EventMeta { /// 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, +} + +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 @@ -239,6 +269,7 @@ impl EventRegistry { description: None, organizer: None, timers: default_timer_selection(), + primary_timer: None, }, state: practice_state, }, @@ -323,9 +354,53 @@ impl EventRegistry { .get_mut(id) .ok_or_else(|| RegistryError(format!("no event with id {:?}", id.0)))?; event.meta.timers = 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; + } + } Ok(event.meta.clone()) } + /// 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(format!("no event with id {:?}", id.0)))?; + if let Some(primary) = &primary { + if !event.meta.timers.contains(primary) { + return Err(RegistryError(format!( + "primary timer {:?} is not in the event's selected timers", + primary.0 + ))); + } + } + event.meta.primary_timer = primary; + Ok(event.meta.clone()) + } + + /// 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 @@ -418,6 +493,7 @@ impl EventRegistry { description: normalize_optional(&request.description), organizer: normalize_optional(&request.organizer), timers: default_timer_selection(), + primary_timer: None, }; reg.events.insert( id, diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index 75b6ec2..9380549 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -175,12 +175,31 @@ pub struct UpdateTimerRequest { pub kind: Option, } -/// The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73). +/// 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). diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index bf6a7d3..0b6036f 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -317,3 +317,119 @@ describe('seam 10: application-level timers + per-event selection (#73)', () => expect((await setEventTimers(event.id, [timer.id])).status).toBe(401); }); }); + +/** + * Issue #112: **primary/alternate timer roles + failover** — redundant timers at one gate, one + * designated **primary**, the rest **alternates**. Only the role designation API is exercised on + * the wire here (the single-active-source feed + failover is proven by the Rust bridge/live tests). + * + * guards: + * - a new event's `EventMeta.primary_timer` is absent (additive `#[serde(default)]`); the first + * selected timer is the effective primary by default. + * - `PUT /events/{id}/timers` accepts an optional `primary` (it must be one of `ids`), recorded on + * `EventMeta.primary_timer`. + * - `PUT /events/{id}/primary-timer` is **RD-gated**, designates a selected timer as primary, + * rejects a primary not in the selection (400), and `null` clears the override. + */ +describe('seam 10b: primary/alternate timer roles (#112)', () => { + /** `PUT /events/{id}/timers` with `{ ids, primary? }` + optional token → raw status + parsed body. */ + async function setEventTimersWithPrimary( + eventId: string, + ids: string[], + primary: string | undefined, + token?: string + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${eventRoot(director.baseUrl, eventId)}/timers`, { + method: 'PUT', + headers, + body: JSON.stringify({ ids, ...(primary !== undefined ? { primary } : {}) }) + }); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + } + + /** `PUT /events/{id}/primary-timer` with `{ id }` (or `{}`) + optional token → status + body. */ + async function setPrimaryTimer( + eventId: string, + id: string | null | undefined, + token?: string + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${eventRoot(director.baseUrl, eventId)}/primary-timer`, { + method: 'PUT', + headers, + body: JSON.stringify(id === undefined ? {} : { id }) + }); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + } + + /** Create two Mock timers + an event selecting both, returning their ids. */ + async function eventWithTwoTimers(): Promise<{ event: string; a: string; b: string }> { + const create = async (name: string) => { + const res = await fetch(`${director.baseUrl}/timers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ name, kind: { Mock: { laps: 1, lap_ms: 50 } } }) + }); + return ((await res.json()) as Timer).id; + }; + const a = await create('Primary Sim'); + const b = await create('Alternate Sim'); + const event = (await createEvent('Redundant Timers', TOKEN)).body as EventMeta; + return { event: event.id, a, b }; + } + + it('a new event has no explicit primary (additive default)', async () => { + const event = (await createEvent('No Primary Yet', TOKEN)).body as EventMeta; + expect(event.primary_timer).toBeUndefined(); + }); + + it('PUT /events/{id}/timers records an optional primary among the selection', async () => { + const { event, a, b } = await eventWithTwoTimers(); + const ok = await setEventTimersWithPrimary(event, [a, b], b, TOKEN); + expect(ok.status).toBe(200); + const meta = ok.body as EventMeta; + expect(meta.timers).toEqual([a, b]); + expect(meta.primary_timer).toBe(b); + + // A primary NOT in the selection → 400 BadRequest. + const bad = await setEventTimersWithPrimary(event, [a, b], 'mock', TOKEN); + expect(bad.status).toBe(400); + }); + + it('PUT /events/{id}/primary-timer designates, rejects out-of-selection, and clears', async () => { + const { event, a, b } = await eventWithTwoTimers(); + await setEventTimersWithPrimary(event, [a, b], undefined, TOKEN); + + // Designate the alternate as primary. + const ok = await setPrimaryTimer(event, b, TOKEN); + expect(ok.status).toBe(200); + expect((ok.body as EventMeta).primary_timer).toBe(b); + + // A primary not in the selection → 400 BadRequest. + const bad = await setPrimaryTimer(event, 'mock', TOKEN); + expect(bad.status).toBe(400); + + // Clearing the override (`null`) → effective primary falls back to the first selected. + const cleared = await setPrimaryTimer(event, null, TOKEN); + expect(cleared.status).toBe(200); + expect((cleared.body as EventMeta).primary_timer).toBeUndefined(); + + // RD-gated: no token → 401. + expect((await setPrimaryTimer(event, a)).status).toBe(401); + }); +}); diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 8a915a3..be4cd3c 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -71,6 +71,7 @@ export type * from '@bindings/ServerHello'; export type * from '@bindings/SessionId'; export type * from '@bindings/SetActiveEventRequest'; export type * from '@bindings/SetEventTimersRequest'; +export type * from '@bindings/SetPrimaryTimerRequest'; export type * from '@bindings/SignalContext'; export type * from '@bindings/Snapshot'; export type * from '@bindings/SourceTime'; From a38dd6ac42b9aa1cc2cad7cc6af1c42c94f39e05 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 01:25:08 +0000 Subject: [PATCH 078/362] fix: reload created events + their metadata on Director restart (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EventRegistry persisted each created event's append-only log to `/.sqlite` and the active-event pointer to `/active-event`, but on boot it only re-seeded the built-in Practice event — it never scanned the data dir to reload the existing event SQLite DBs. So after a Director restart created events vanished (`GET /events` listed only `practice`) and the persisted active-event id degraded to the picker because its event was never loaded. The EventMeta (name, descriptive fields, timer selection, primary) also lived only in memory, so even scanning the files would not recover an event's config. This change makes each event self-contained and reloads it on boot: - storage: add a sidecar key→value `meta` table to `SqliteLog` with `get_meta`/`set_meta` (upsert) — mutable per-event config, separate from the append-only `log`. - server: persist each event's `EventMeta` as JSON into its own SQLite `meta` table on create, and write it through on every meta mutation (`set_timers`/`set_primary_timer`). - server: on boot, scan `` for `*.sqlite` event logs, restore each event's `EventMeta` + its log into the registry, then restore the active-event pointer (which now resolves to a reloaded event). A bad / unparseable / meta-less file is skipped, never fatal; a stray `practice.sqlite` can't shadow the built-in in-memory Practice event. Tests: a restart test that creates an event (name + descriptive fields + non-default timer selection + primary + a log fact), drops and rebuilds the registry over the same data dir, and asserts the event is listed with its metadata intact, its log survives, and the active-event restores; plus a storage test for the `meta` table upsert/persist contract. The prior test that asserted the buggy "created events are not restored" behaviour is updated to the fixed expectation. `cargo xtask ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/server/src/events.rs | 209 ++++++++++++++++++++++++++++-- crates/storage/src/sqlite.rs | 55 ++++++++ crates/storage/tests/behaviour.rs | 33 +++++ 3 files changed, 289 insertions(+), 8 deletions(-) diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 226c06e..a4f6113 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -56,6 +56,15 @@ pub const PRACTICE_EVENT_NAME: &str = "Practice"; /// #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 @@ -279,6 +288,13 @@ impl EventRegistry { std::fs::create_dir_all(dir).map_err(|e| { RegistryError(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` @@ -362,7 +378,12 @@ impl EventRegistry { event.meta.primary_timer = None; } } - Ok(event.meta.clone()) + 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`]. @@ -392,7 +413,12 @@ impl EventRegistry { } } event.meta.primary_timer = primary; - Ok(event.meta.clone()) + 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) } /// An event's full [`EventMeta`] (issue #112), or `None` if no such event — the source bridge @@ -495,6 +521,15 @@ impl EventRegistry { timers: default_timer_selection(), primary_timer: None, }; + // 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 { @@ -516,7 +551,92 @@ impl EventRegistry { /// The SQLite file an event's log lives in under `dir`: `

      /.sqlite`. fn event_db_path(dir: &Path, id: &EventId) -> PathBuf { - dir.join(format!("{}.sqlite", id.0)) + dir.join(format!("{}{}", id.0, EVENT_DB_SUFFIX)) +} + +/// 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(format!( + "could not open event log {} to persist meta: {e}", + path.display() + )) + })?; + let json = serde_json::to_string(meta) + .map_err(|e| RegistryError(format!("could not serialise event meta: {e}")))?; + log.set_meta(EVENT_META_KEY, &json) + .map_err(|e| RegistryError(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(_) => continue, // unreadable file — skip, don't fail boot + }; + let meta = match log.get_meta(EVENT_META_KEY) { + Ok(Some(json)) => match serde_json::from_str::(&json) { + Ok(meta) => meta, + Err(_) => continue, // unparseable meta — skip + }, + // 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) | Err(_) => 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). @@ -756,12 +876,11 @@ mod tests { 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… + // 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(); - // …as long as that event still exists. The created event's SQLite file is on disk, - // but the registry only re-seeds Practice on boot (created events are not re-listed - // here), so a created-id active pointer is dropped as stale — assert Practice instead. - assert!(reopened.active().is_none()); + 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(); @@ -774,6 +893,80 @@ mod tests { 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())], + }, + 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 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())); 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..c6baa3b 100644 --- a/crates/storage/tests/behaviour.rs +++ b/crates/storage/tests/behaviour.rs @@ -164,6 +164,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 From 9d180d4a78cf78aff22a4f865ed7c23ae4a8a7e1 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 01:28:51 +0000 Subject: [PATCH 079/362] protocol-client + session: add setPrimaryTimer (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror setEventTimers' full-trust-first → lazy-token pattern for the new PUT /events/{id}/primary-timer route: `setPrimaryTimer(baseUrl, eventId, id, token?)` in protocol-client (sends `{ id }`, `null` clears the override), and `session.setPrimaryTimer(id)` which re-homes `currentEvent` with the server's EventMeta. Add a `session.primaryTimerId` getter that resolves the effective primary via the "first selected = primary when null" rule (explicit `primary_timer` when set and still selected, else the first selected timer). Cover the client call shape (id + null-clear), the session no-op/re-home/ auth-retry paths, and the `primaryTimerId` rule. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../apps/rd-console/src/lib/session.svelte.ts | 37 ++++++++++++ .../rd-console/tests/session.svelte.test.ts | 60 +++++++++++++++++++ frontend/apps/rd-console/tests/support.ts | 7 ++- .../protocol-client/src/client.test.ts | 56 +++++++++++++++++ .../packages/protocol-client/src/client.ts | 30 ++++++++++ .../packages/protocol-client/src/index.ts | 1 + 6 files changed, 189 insertions(+), 2 deletions(-) diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 9c05d9c..a238a0f 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -47,6 +47,7 @@ import { updateTimer, deleteTimer, setEventTimers, + setPrimaryTimer, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -205,6 +206,7 @@ export class Session { #updateTimerImpl: typeof updateTimer; #deleteTimerImpl: typeof deleteTimer; #setEventTimersImpl: typeof setEventTimers; + #setPrimaryTimerImpl: typeof setPrimaryTimer; constructor(opts?: { connectImpl?: typeof connect; @@ -218,6 +220,7 @@ export class Session { updateTimerImpl?: typeof updateTimer; deleteTimerImpl?: typeof deleteTimer; setEventTimersImpl?: typeof setEventTimers; + setPrimaryTimerImpl?: typeof setPrimaryTimer; baseUrl?: string; autoRestore?: boolean; }) { @@ -232,6 +235,7 @@ export class Session { this.#updateTimerImpl = opts?.updateTimerImpl ?? updateTimer; this.#deleteTimerImpl = opts?.deleteTimerImpl ?? deleteTimer; this.#setEventTimersImpl = opts?.setEventTimersImpl ?? setEventTimers; + this.#setPrimaryTimerImpl = opts?.setPrimaryTimerImpl ?? setPrimaryTimer; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -365,6 +369,25 @@ export class Session { return updated; } + /** + * Designate the **current event's** primary timer (`PUT /events/{id}/primary-timer`) — issue + * #112. Among the selected timers exactly one is the primary (it feeds the race); the rest are + * alternates. Pass a timer `id` (it must be one of the event's selected timers) to make it + * primary, or `null` to clear the override so the **first** selected timer is the effective + * primary. No-op (resolves `undefined`) when no event is selected. On success the updated + * {@link EventMeta} replaces {@link currentEvent}; returns it, `undefined` on a cancelled prompt, + * or throws. + */ + async setPrimaryTimer(id: TimerId | null): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#setPrimaryTimerImpl(this.baseUrl, event.id, id, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + /** * Read the id of the Director's **active event** (`GET /active-event`, open, no token) — issue * #91. The picker uses this to mark which event is currently live, independent of whether this @@ -503,6 +526,20 @@ export class Session { return ids.map((id) => byId.get(id)).filter((t): t is Timer => t !== undefined); } + /** + * The current event's **effective primary** timer id (issue #112), applying the "first selected + * = primary when null" rule: `EventMeta.primary_timer` when set and still in the selection, + * otherwise the first selected timer. `undefined` when no event/selection. The remaining selected + * timers are alternates (hot standby). + */ + get primaryTimerId(): TimerId | undefined { + const ids = this.currentEvent?.timers; + if (!ids?.length) return undefined; + const explicit = this.currentEvent?.primary_timer; + if (explicit && ids.includes(explicit)) return explicit; + return ids[0]; + } + /** Re-scope the live read client within the current event (e.g. to a heat scope). */ resubscribe(scope: Scope): void { const event = this.currentEvent; diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 13b9fb9..dfd4fc5 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -465,6 +465,7 @@ describe('Session', () => { updateTimerImpl?: ReturnType; deleteTimerImpl?: ReturnType; setEventTimersImpl?: ReturnType; + setPrimaryTimerImpl?: ReturnType; }) { const { connect } = mockConnect(connecting); const controlFactory = vi.fn(() => ({ @@ -654,5 +655,64 @@ describe('Session', () => { ); expect(session.currentEvent?.timers).toEqual(['mock', 'rh-1']); }); + + it('setPrimaryTimer is a no-op (undefined) with no event selected', async () => { + const setPrimaryTimerImpl = vi.fn(); + const session = timerSession({ setPrimaryTimerImpl }); + const result = await session.setPrimaryTimer('mock'); + expect(result).toBeUndefined(); + expect(setPrimaryTimerImpl).not.toHaveBeenCalled(); + }); + + it('setPrimaryTimer designates and re-homes currentEvent with the server response', async () => { + const updated: EventMeta = { ...PRACTICE, timers: ['mock', 'rh-1'], primary_timer: 'rh-1' }; + const setPrimaryTimerImpl = vi.fn(async () => updated); + const session = timerSession({ setPrimaryTimerImpl }); + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'] }); + const result = await session.setPrimaryTimer('rh-1'); + expect(result).toEqual(updated); + expect(setPrimaryTimerImpl).toHaveBeenCalledWith( + 'http://d.local', + 'practice', + 'rh-1', + undefined + ); + expect(session.currentEvent?.primary_timer).toBe('rh-1'); + }); + + it('primaryTimerId applies the "first selected = primary when null" rule', async () => { + const session = timerSession({}); + // No event → undefined. + expect(session.primaryTimerId).toBeUndefined(); + // No explicit primary → the first selected timer. + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'] }); + expect(session.primaryTimerId).toBe('mock'); + // An explicit, in-selection primary wins. + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'], primary_timer: 'rh-1' }); + expect(session.primaryTimerId).toBe('rh-1'); + // An explicit primary NOT in the selection is ignored → first selected. + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'], primary_timer: 'gone' }); + expect(session.primaryTimerId).toBe('mock'); + }); + + it('setPrimaryTimer prompts then retries once on an auth (401) failure', async () => { + const updated: EventMeta = { ...PRACTICE, timers: ['mock', 'rh-1'], primary_timer: 'rh-1' }; + const setPrimaryTimerImpl = vi + .fn() + .mockRejectedValueOnce(new Error('PUT /events/practice/primary-timer failed: HTTP 401')) + .mockResolvedValueOnce(updated); + const session = timerSession({ setPrimaryTimerImpl }); + session.selectEvent({ ...PRACTICE, timers: ['mock', 'rh-1'] }); + session.setTokenProvider(async () => 'tok'); + const result = await session.setPrimaryTimer('rh-1'); + expect(result).toEqual(updated); + expect(setPrimaryTimerImpl).toHaveBeenCalledTimes(2); + expect(setPrimaryTimerImpl).toHaveBeenLastCalledWith( + 'http://d.local', + 'practice', + 'rh-1', + 'tok' + ); + }); }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 5e8926a..525412d 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -12,7 +12,8 @@ import type { createTimer, updateTimer, deleteTimer, - setEventTimers + setEventTimers, + setPrimaryTimer } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -33,6 +34,7 @@ export interface TimerImpls { updateTimerImpl?: typeof updateTimer; deleteTimerImpl?: typeof deleteTimer; setEventTimersImpl?: typeof setEventTimers; + setPrimaryTimerImpl?: typeof setPrimaryTimer; } export interface TestSession { @@ -76,7 +78,8 @@ export function makeTestSession( createTimerImpl: opts?.createTimerImpl, updateTimerImpl: opts?.updateTimerImpl, deleteTimerImpl: opts?.deleteTimerImpl, - setEventTimersImpl: opts?.setEventTimersImpl + setEventTimersImpl: opts?.setEventTimersImpl, + setPrimaryTimerImpl: opts?.setPrimaryTimerImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event. session.setToken('tok'); diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index c96a3b9..bc357ee 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -575,4 +575,60 @@ describe('events lifecycle helpers (#72)', () => { expect(seen[0].body).toEqual({ ids: ['mock', 'field-rh-ab12'] }); expect(meta.timers).toEqual(['mock', 'field-rh-ab12']); }); + + it('setPrimaryTimer PUTs { id } to /events/{id}/primary-timer and returns the updated EventMeta', async () => { + const seen: { url: string; method?: string; body?: unknown }[] = []; + const fetch: FetchLike = async (input, init) => { + seen.push({ + url: String(input), + method: init?.method, + body: JSON.parse(String(init?.body)) + }); + return { + ok: true, + status: 200, + json: async (): Promise => ({ + id: 'evt-a', + name: 'Friday Series', + created_at: 1, + persistent: true, + timers: ['mock', 'field-rh-ab12'], + primary_timer: 'field-rh-ab12' + }) + } as unknown as Response; + }; + const { setPrimaryTimer } = await import('./client.js'); + const meta = await setPrimaryTimer( + 'http://director.local:8080', + 'evt-a', + 'field-rh-ab12', + 'rd-tok', + { fetch } + ); + expect(seen[0].url).toBe('http://director.local:8080/events/evt-a/primary-timer'); + expect(seen[0].method).toBe('PUT'); + expect(seen[0].body).toEqual({ id: 'field-rh-ab12' }); + expect(meta.primary_timer).toBe('field-rh-ab12'); + }); + + it('setPrimaryTimer sends { id: null } to clear the override', async () => { + const seen: { body?: unknown }[] = []; + const fetch: FetchLike = async (_input, init) => { + seen.push({ body: JSON.parse(String(init?.body)) }); + return { + ok: true, + status: 200, + json: async (): Promise => ({ + id: 'evt-a', + name: 'Friday Series', + created_at: 1, + persistent: true, + timers: ['mock', 'field-rh-ab12'] + }) + } as unknown as Response; + }; + const { setPrimaryTimer } = await import('./client.js'); + await setPrimaryTimer('http://director.local:8080', 'evt-a', null, 'rd-tok', { fetch }); + expect(seen[0].body).toEqual({ id: null }); + }); }); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 49b7687..f767def 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -418,6 +418,36 @@ export async function setEventTimers( return (await resp.json()) as EventMeta; } +/** + * Designate an event's **primary** timer (`PUT /events/{id}/primary-timer`) — issue #112. Among + * the event's selected timers, exactly one is the primary (it feeds the race); the rest are + * **alternates** (hot standby). Pass `id` to make that timer primary (it must be one of the + * event's currently-selected timers, else **400**); pass `null` to clear the override so the + * **first** selected timer becomes the effective primary. RD-gated. Resolves to the updated event + * {@link EventMeta}, or rejects on a non-2xx / transport failure. + */ +export async function setPrimaryTimer( + baseUrl: string, + eventId: EventId, + id: TimerId | null, + token?: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}${eventRoot(eventId)}/primary-timer`, { + method: 'PUT', + headers, + body: JSON.stringify({ id }) + }); + if (!resp.ok) throw new Error(`PUT /events/${eventId}/primary-timer failed: HTTP ${resp.status}`); + return (await resp.json()) as EventMeta; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 8191485..d16b47f 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -23,6 +23,7 @@ export { updateTimer, deleteTimer, setEventTimers, + setPrimaryTimer, PRACTICE_EVENT_ID } from './client.js'; export type { From 585136a50fabfe38eb0d302b185258f4cf0ecca5 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 01:28:59 +0000 Subject: [PATCH 080/362] rd-console: primary/alternate timer roles UI (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the per-event Timers screen, when 2+ timers are selected, add a "Timer roles" card: a radio per selected timer (in saved order) designates the **Primary** (feeds the race); the rest are labeled **Alternate** (hot standby). Selecting a new primary calls `session.setPrimaryTimer(id)`, and the current selection reflects `EventMeta.primary_timer` with the "first selected = primary when null" rule. A one-line hint explains failover. Roles are hidden for a single-timer event (trivially primary — no noise). Subtle context-header nicety: when 2+ timers feed the event, each status pill gets a small "P"/"A" marker so the active source is glanceable from any in-event screen. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../apps/rd-console/src/ContextHeader.svelte | 35 ++++- .../rd-console/src/screens/EventTimers.svelte | 137 ++++++++++++++++++ .../apps/rd-console/tests/EventTimers.test.ts | 71 +++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index 2e02ece..6ecb195 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -41,6 +41,10 @@ // The active event's selected timers with their live (polled) connection status (#73, Slice 2b). // A compact pill per timer keeps "is the timer still connected?" answerable from any in-event page. const timers = $derived(session.selectedTimers); + // The effective primary (issue #112) — marked subtly on its pill when 2+ timers feed the event, + // so "which one is live?" is answerable at a glance. A single timer is trivially primary (no marker). + const primaryId = $derived(session.primaryTimerId); + const showRoles = $derived(timers.length >= 2); // Only show a phase once there's a live heat on the timer; otherwise the event is idle. const phase = $derived(heat ? (live?.phase ?? 'Scheduled') : undefined); @@ -83,7 +87,18 @@ {#if timers.length}
      {#each timers as timer (timer.id)} - + {@const isPrimary = showRoles && timer.id === primaryId} + + {#if showRoles} + + {/if} {timer.name} @@ -220,6 +235,24 @@ white-space: nowrap; max-width: 8rem; } + /* Subtle primary/alternate marker (#112): a tiny "P"/"A" chip on each pill when 2+ timers feed. */ + .ctx-role { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex-shrink: 0; + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); + border-radius: var(--gf-radius-pill); + color: var(--gf-text-muted); + background: var(--gf-surface-sunken); + } + .ctx-role.role-primary { + color: var(--gf-success); + background: var(--gf-success-soft); + } /* ── Connection + switch ───────────────────────────────────────────────────── */ .ctx-conn { diff --git a/frontend/apps/rd-console/src/screens/EventTimers.svelte b/frontend/apps/rd-console/src/screens/EventTimers.svelte index c4f85bd..7d73dc6 100644 --- a/frontend/apps/rd-console/src/screens/EventTimers.svelte +++ b/frontend/apps/rd-console/src/screens/EventTimers.svelte @@ -93,6 +93,49 @@ function reset() { syncFromEvent(); } + + // ── Primary / alternate roles (issue #112) ──────────────────────────────── + // + // Roles are a property of the event's **saved** selection (the timers actually feeding the + // race), not the unsaved working set — `EventMeta.primary_timer` is server state keyed to the + // saved timers. So the role picker reflects `savedSelection`, in its saved order. + // + // Effective primary: the event's `primary_timer` when it's set and still in the selection; + // otherwise the **first** selected timer (the "first selected = primary when null" rule). Only + // surfaced when 2+ timers are selected — a lone timer is trivially the primary, no UI noise. + + // The saved selection as registry rows (so we can show names), in saved order. + const roleTimers = $derived( + savedSelection + .map((id) => timers.find((t) => t.id === id)) + .filter((t): t is Timer => t !== undefined) + ); + + const showRoles = $derived(roleTimers.length >= 2); + + // The currently-effective primary id, applying the "first selected = primary when null" rule + // (shared with the context header via the session getter). + const effectivePrimary = $derived(session.primaryTimerId); + + // Guards a primary change in flight so the radios don't double-fire mid-request. + let settingPrimary = $state(false); + + async function choosePrimary(id: TimerId) { + if (settingPrimary || id === effectivePrimary) return; + settingPrimary = true; + try { + const updated = await session.setPrimaryTimer(id); + if (!updated) { + toast.info('A control token is required to set the primary timer.'); + return; + } + toast.success('Primary timer updated.'); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + settingPrimary = false; + } + }
      @@ -138,11 +181,105 @@ {/snippet} + + {#if showRoles} + +
        + {#each roleTimers as timer (timer.id)} + {@const isPrimary = timer.id === effectivePrimary} +
      • + + + {isPrimary ? 'Primary' : 'Alternate'} + +
      • + {/each} +
      +
      + {/if}
      diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index a238a0f..bcbce6c 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -48,6 +48,7 @@ import { deleteTimer, setEventTimers, setPrimaryTimer, + listPilots, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -62,6 +63,7 @@ import type { HeatId, HeatResult, LiveRaceState, + Pilot, ProjectionBody, Scope, Timer, @@ -207,6 +209,7 @@ export class Session { #deleteTimerImpl: typeof deleteTimer; #setEventTimersImpl: typeof setEventTimers; #setPrimaryTimerImpl: typeof setPrimaryTimer; + #listPilotsImpl: typeof listPilots; constructor(opts?: { connectImpl?: typeof connect; @@ -221,6 +224,7 @@ export class Session { deleteTimerImpl?: typeof deleteTimer; setEventTimersImpl?: typeof setEventTimers; setPrimaryTimerImpl?: typeof setPrimaryTimer; + listPilotsImpl?: typeof listPilots; baseUrl?: string; autoRestore?: boolean; }) { @@ -236,6 +240,7 @@ export class Session { this.#deleteTimerImpl = opts?.deleteTimerImpl ?? deleteTimer; this.#setEventTimersImpl = opts?.setEventTimersImpl ?? setEventTimers; this.#setPrimaryTimerImpl = opts?.setPrimaryTimerImpl ?? setPrimaryTimer; + this.#listPilotsImpl = opts?.listPilotsImpl ?? listPilots; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -303,6 +308,18 @@ export class Session { return this.#listTimersImpl(this.baseUrl, { token: this.#token }); } + /** + * List every pilot in the application-level directory (`GET /pilots`, open, no token) — issue + * #74. Like timers, pilots are app-level configuration (a persisted directory the RD maintains + * once, rostered per event), so this lives on the session and is reachable from the home hub's + * **Pilots** page. Rejects on a transport/HTTP failure (the page surfaces it). The full pilot + * directory management (`PilotManager`) is the next slice; for now this read backs the hub's + * Pilots count and the placeholder page's read-only callsign list. + */ + listPilots(): Promise { + return this.#listPilotsImpl(this.baseUrl, { token: this.#token }); + } + /** * Run a control-gated write with the full-trust-then-lazy-prompt retry: call `attempt` with * the currently-held token; if it fails for **auth** and no token is held yet, prompt once and diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index 9e336b3..6f9db4c 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -1,12 +1,16 @@ + +
      +
      +
      + +
      + GridFPV + Race Director Console +
      +
      + +
      + + + + + +
      +
      +
      + + diff --git a/frontend/apps/rd-console/src/screens/PilotsPage.svelte b/frontend/apps/rd-console/src/screens/PilotsPage.svelte new file mode 100644 index 0000000..5a4703e --- /dev/null +++ b/frontend/apps/rd-console/src/screens/PilotsPage.svelte @@ -0,0 +1,225 @@ + + +
      +
      + + +
      +

      Pilots

      +

      + The application-level pilot directory — maintained once, rostered per event. +

      +
      + + +
      + {#if loadState.kind === 'loading'} +
      + + Loading pilots… +
      + {:else if loadState.kind === 'error'} +
      +

      Couldn't reach the Director.

      + {loadState.message} + +
      + {:else if loadState.pilots.length === 0} +
      +

      No pilots in the directory yet.

      +
      + {:else} +
        + {#each loadState.pilots as pilot (pilot.id)} +
      • + {pilot.callsign} + {#if pilot.name}{pilot.name}{/if} + {#if pilot.team}{pilot.team}{/if} +
      • + {/each} +
      + {/if} + + +

      + Coming soon + Pilot registration UI (add, edit, remove, cloud-pull import) lands in the next slice. +

      +
      +
      +
      +
      + + diff --git a/frontend/apps/rd-console/src/screens/Timers.svelte b/frontend/apps/rd-console/src/screens/Timers.svelte deleted file mode 100644 index 8337ac1..0000000 --- a/frontend/apps/rd-console/src/screens/Timers.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - -
      -

      - Timers are configured once and reused across events. Each event picks which timers it uses; - the built-in Mock source flies a synthetic race with no hardware. -

      - - -
      - - {#snippet footer()} - - - {/snippet} -
      - - diff --git a/frontend/apps/rd-console/src/screens/TimersPage.svelte b/frontend/apps/rd-console/src/screens/TimersPage.svelte new file mode 100644 index 0000000..ee0984a --- /dev/null +++ b/frontend/apps/rd-console/src/screens/TimersPage.svelte @@ -0,0 +1,97 @@ + + +
      +
      + + +
      +
      +

      Timers

      +

      + Timers are configured once and reused across events. Each event picks which timers it + uses; the built-in Mock source flies a synthetic race with no hardware. +

      +
      + +
      + + +
      + +
      +
      +
      +
      + + diff --git a/frontend/apps/rd-console/tests/Breadcrumbs.test.ts b/frontend/apps/rd-console/tests/Breadcrumbs.test.ts new file mode 100644 index 0000000..00b1ab5 --- /dev/null +++ b/frontend/apps/rd-console/tests/Breadcrumbs.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import Breadcrumbs from '../src/Breadcrumbs.svelte'; + +describe('Breadcrumbs (#118)', () => { + it('renders intermediate crumbs as buttons and the last as the current page', () => { + const onhome = vi.fn(); + render(Breadcrumbs, { + crumbs: [{ label: 'Home', onclick: onhome }, { label: 'Events' }, { label: 'Friday' }] + }); + + // Home is a navigable button; the leaf is aria-current, not a button. + expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument(); + const leaf = screen.getByText('Friday'); + expect(leaf).toHaveAttribute('aria-current', 'page'); + expect(screen.queryByRole('button', { name: 'Friday' })).toBeNull(); + }); + + it('invokes a crumb onclick when navigated', async () => { + const onhome = vi.fn(); + const onevents = vi.fn(); + render(Breadcrumbs, { + crumbs: [ + { label: 'Home', onclick: onhome }, + { label: 'Events', onclick: onevents }, + { label: 'Friday' } + ] + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Home' })); + await fireEvent.click(screen.getByRole('button', { name: 'Events' })); + expect(onhome).toHaveBeenCalledTimes(1); + expect(onevents).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts new file mode 100644 index 0000000..acfb4ab --- /dev/null +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { EventMeta, Pilot, Timer } from '@gridfpv/types'; +import HomeHub from '../src/screens/HomeHub.svelte'; +import { makeTestSession } from './support.js'; + +const PILOTS: Pilot[] = [ + { id: 'p1', callsign: 'Ace', attributes: {} }, + { id: 'p2', callsign: 'Bee', attributes: {} } +]; +const EVENTS: EventMeta[] = [ + { + id: 'practice', + name: 'Practice', + created_at: 0, + persistent: false, + timers: ['mock'], + roster: [] + }, + { id: 'e1', name: 'Friday', created_at: 1, persistent: true, timers: ['mock'], roster: [] } +]; +const TIMERS: Timer[] = [ + { id: 'mock', name: 'Mock', kind: { Mock: { laps: 3, lap_ms: 30000 } }, status: 'Ready' }, + { + id: 'rh-1', + name: 'Track RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Configured' + } +]; + +describe('HomeHub (app-level landing, #118)', () => { + function setup() { + const listPilotsImpl = vi.fn(async () => PILOTS); + const listEventsImpl = vi.fn(async () => EVENTS); + const listTimersImpl = vi.fn(async () => TIMERS); + // The hub is the no-event landing, so render with no event entered. + const session = makeTestSession({ noEnter: true, listTimersImpl, listPilotsImpl }).session; + // listEvents isn't a constructor seam, so stub it directly. + vi.spyOn(session, 'listEvents').mockImplementation(listEventsImpl); + return { session }; + } + + it('renders the three cards with their summary counts', async () => { + const { session } = setup(); + const onpilots = vi.fn(); + const onevents = vi.fn(); + const ontimers = vi.fn(); + render(HomeHub, { session, onpilots, onevents, ontimers }); + + // Three navigable cards. + expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument(); + + // Summaries settle per card (count + unit are separate spans): 2 pilots, 2 events, + // 2 timers · 1 connected (only Mock is Ready). + const pilotsCard = screen.getByRole('heading', { name: 'Pilots' }).closest('button')!; + const eventsCard = screen.getByRole('heading', { name: 'Events' }).closest('button')!; + const timersCard = screen.getByRole('heading', { name: 'Timers' }).closest('button')!; + await waitFor(() => expect(within(pilotsCard).getByText('2')).toBeInTheDocument()); + expect(within(pilotsCard).getByText('pilots')).toBeInTheDocument(); + await waitFor(() => expect(within(eventsCard).getByText('2')).toBeInTheDocument()); + expect(within(eventsCard).getByText('events')).toBeInTheDocument(); + await waitFor(() => expect(within(timersCard).getByText('timers')).toBeInTheDocument()); + expect(within(timersCard).getByText(/1 connected/)).toBeInTheDocument(); + }); + + it('navigates to each page on card click', async () => { + const { session } = setup(); + const onpilots = vi.fn(); + const onevents = vi.fn(); + const ontimers = vi.fn(); + render(HomeHub, { session, onpilots, onevents, ontimers }); + + await fireEvent.click(screen.getByRole('heading', { name: 'Pilots' }).closest('button')!); + await fireEvent.click(screen.getByRole('heading', { name: 'Events' }).closest('button')!); + await fireEvent.click(screen.getByRole('heading', { name: 'Timers' }).closest('button')!); + + expect(onpilots).toHaveBeenCalledTimes(1); + expect(onevents).toHaveBeenCalledTimes(1); + expect(ontimers).toHaveBeenCalledTimes(1); + }); + + it('shows a dash when a summary read fails', async () => { + const listTimersImpl = vi.fn(async () => { + throw new Error('unreachable'); + }); + const session = makeTestSession({ noEnter: true, listTimersImpl }).session; + vi.spyOn(session, 'listEvents').mockResolvedValue(EVENTS); + vi.spyOn(session, 'listPilots').mockResolvedValue(PILOTS); + render(HomeHub, { session, onpilots: vi.fn(), onevents: vi.fn(), ontimers: vi.fn() }); + + const timersCard = screen.getByRole('heading', { name: 'Timers' }).closest('button')!; + await waitFor(() => expect(within(timersCard).getByText('—')).toBeInTheDocument()); + }); +}); diff --git a/frontend/apps/rd-console/tests/PilotsPage.test.ts b/frontend/apps/rd-console/tests/PilotsPage.test.ts new file mode 100644 index 0000000..1ce88d7 --- /dev/null +++ b/frontend/apps/rd-console/tests/PilotsPage.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { waitFor } from '@testing-library/dom'; +import type { Pilot } from '@gridfpv/types'; +import PilotsPage from '../src/screens/PilotsPage.svelte'; +import { makeTestSession } from './support.js'; + +const noop = () => {}; + +const PILOTS: Pilot[] = [ + { id: 'p1', callsign: 'Ace', name: 'Alice', attributes: {} }, + { id: 'p2', callsign: 'Bee', attributes: {} } +]; + +describe('PilotsPage (placeholder for #74)', () => { + it('lists the directory callsigns read-only and notes the registration UI is coming', async () => { + const listPilotsImpl = vi.fn(async () => PILOTS); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); + render(PilotsPage, { session, onhome: noop }); + + // The read-only callsigns appear. + await screen.findByText('Ace'); + expect(screen.getByText('Bee')).toBeInTheDocument(); + // The "coming" note seam for the next slice. + expect(screen.getByText(/registration UI/i)).toBeInTheDocument(); + // No management controls in this slice. + expect(screen.queryByRole('button', { name: /Add pilot/i })).toBeNull(); + }); + + it('surfaces a read error with a retry', async () => { + const listPilotsImpl = vi.fn(async () => { + throw new Error('GET /pilots failed: HTTP 503'); + }); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); + render(PilotsPage, { session, onhome: noop }); + + await waitFor(() => expect(screen.getByText(/HTTP 503/)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/Timers.test.ts b/frontend/apps/rd-console/tests/TimersPage.test.ts similarity index 88% rename from frontend/apps/rd-console/tests/Timers.test.ts rename to frontend/apps/rd-console/tests/TimersPage.test.ts index 271c952..692fb18 100644 --- a/frontend/apps/rd-console/tests/Timers.test.ts +++ b/frontend/apps/rd-console/tests/TimersPage.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/svelte'; import { fireEvent, waitFor } from '@testing-library/dom'; import type { Timer } from '@gridfpv/types'; -import Timers from '../src/screens/Timers.svelte'; +import TimersPage from '../src/screens/TimersPage.svelte'; import { makeTestSession } from './support.js'; +/** The page hosts the shared TimerManager; tests render it with a no-op `onhome`. */ +const noop = () => {}; + const MOCK: Timer = { id: 'mock', name: 'Mock', @@ -18,11 +21,11 @@ const RH: Timer = { status: 'Configured' }; -describe('Timers (picker CRUD modal)', () => { - it('lists the registry on open, with the built-in Mock undeletable', async () => { +describe('TimersPage (app-level timer registry)', () => { + it('lists the registry on mount, with the built-in Mock undeletable', async () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); const { session } = makeTestSession({ listTimersImpl }); - render(Timers, { session, open: true }); + render(TimersPage, { session, onhome: noop }); await screen.findByText('Mock'); expect(screen.getByText('Track RH')).toBeInTheDocument(); @@ -45,7 +48,7 @@ describe('Timers (picker CRUD modal)', () => { const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); const createTimerImpl = vi.fn(async () => created); const { session } = makeTestSession({ listTimersImpl, createTimerImpl }); - render(Timers, { session, open: true }); + render(TimersPage, { session, onhome: noop }); await screen.findByText('Mock'); await fireEvent.click(screen.getByRole('button', { name: '+ Add timer' })); @@ -68,7 +71,7 @@ describe('Timers (picker CRUD modal)', () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); const updateTimerImpl = vi.fn(async () => ({ ...RH, name: 'Renamed' })); const { session } = makeTestSession({ listTimersImpl, updateTimerImpl }); - render(Timers, { session, open: true }); + render(TimersPage, { session, onhome: noop }); await screen.findByText('Track RH'); const list = screen.getByRole('list', { name: 'Configured timers' }); @@ -93,7 +96,7 @@ describe('Timers (picker CRUD modal)', () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); const deleteTimerImpl = vi.fn(async () => undefined as unknown as void); const { session } = makeTestSession({ listTimersImpl, deleteTimerImpl }); - render(Timers, { session, open: true }); + render(TimersPage, { session, onhome: noop }); await screen.findByText('Track RH'); const list = screen.getByRole('list', { name: 'Configured timers' }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 3abe369..2c5b786 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -13,7 +13,8 @@ import type { updateTimer, deleteTimer, setEventTimers, - setPrimaryTimer + setPrimaryTimer, + listPilots } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -28,7 +29,7 @@ const PRACTICE: EventMeta = { roster: [] }; -/** The timer-registry seams a screen test can override (all optional; defaults are inert). */ +/** The registry seams a screen test can override (all optional; defaults are inert). */ export interface TimerImpls { listTimersImpl?: typeof listTimers; createTimerImpl?: typeof createTimer; @@ -36,6 +37,8 @@ export interface TimerImpls { deleteTimerImpl?: typeof deleteTimer; setEventTimersImpl?: typeof setEventTimers; setPrimaryTimerImpl?: typeof setPrimaryTimer; + /** The app-level pilot directory read (issue #74) — backs the hub + Pilots page tests. */ + listPilotsImpl?: typeof listPilots; } export interface TestSession { @@ -45,7 +48,13 @@ export interface TestSession { } export function makeTestSession( - opts?: { ack?: CommandAck; live?: LiveRaceState; event?: EventMeta } & TimerImpls + opts?: { + ack?: CommandAck; + live?: LiveRaceState; + event?: EventMeta; + /** Skip entering an event — for the app-level hub/page tests that render with no event. */ + noEnter?: boolean; + } & TimerImpls ): TestSession { const ack: CommandAck = opts?.ack ?? { ok: true }; const sendSpy = vi.fn<(c: Command) => Promise>(async () => ack); @@ -80,11 +89,13 @@ export function makeTestSession( updateTimerImpl: opts?.updateTimerImpl, deleteTimerImpl: opts?.deleteTimerImpl, setEventTimersImpl: opts?.setEventTimersImpl, - setPrimaryTimerImpl: opts?.setPrimaryTimerImpl + setPrimaryTimerImpl: opts?.setPrimaryTimerImpl, + listPilotsImpl: opts?.listPilotsImpl }); - // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event. + // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, + // unless the test wants the app-level (no-event) context. session.setToken('tok'); - session.selectEvent(opts?.event ?? PRACTICE); + if (!opts?.noEnter) session.selectEvent(opts?.event ?? PRACTICE); const pushLive = (state: LiveRaceState) => listener?.({ body: { LiveRaceState: state }, cursor: 1, status: 'live', error: undefined }); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index b38c434..d454e13 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -1,16 +1,16 @@ /** * Full RD console click-through against a real Director (#13, v0.4 Director wiring + - * observability harness; entry reshaped for the event-picker landing, #72 Slice 1b). + * observability harness; entry reshaped for the home-hub IA, #118). * - * This is the deliverable proof: a person opens the RD console, lands on the **event - * picker**, enters **Practice**, defines a heat with named pilots, runs it, **watches the live - * laps climb in the rendered DOM**, finishes + scores, and reads results with the pilots and - * their lap counts — every step a real click/input in headless chromium, every command on the + * This is the deliverable proof: a person opens the RD console, lands on the **home hub**, opens + * the **Events** page, enters **Practice**, defines a heat with named pilots, runs it, **watches + * the live laps climb in the rendered DOM**, finishes + scores, and reads results with the pilots + * and their lap counts — every step a real click/input in headless chromium, every command on the * real control path, every lap from the real built-in sim source. Nothing is mocked. * * The Director is booted with **no token configured**, so control is **open** (full-trust by - * default, #72 Slice 1b): the run goes picker → Practice → build heat → control with **no token - * step** — the lazy prompt never fires. (The token-gated path is a separate, optional concern.) + * default, #72 Slice 1b): the run goes hub → Events → Practice → build heat → control with **no + * token step** — the lazy prompt never fires. (The token-gated path is a separate, optional concern.) * * The lap-counts-climbing assertion is the load-bearing one: it polls the per-pilot lap * numbers rendered in the HeatSheet and asserts they increase over a couple of seconds — @@ -29,12 +29,13 @@ const PILOTS = ['Ace', 'Bee', 'Cee']; const HEAT_ID = 'q-1'; test('RD drives a full basic sim race through the console UI', async ({ page }) => { - // ── Open the console: the event picker is the landing screen (#72) ─────────────────── + // ── Open the console: the home hub is the landing screen (#118) ────────────────────── await page.goto('/'); - // ── Enter the always-present Practice event (no token needed to browse/enter) ──────── - // The picker reads the open event list and renders Practice prominently; clicking it - // enters the event workspace. The Director is the page's own origin — no address to type. + // ── Hub → Events page → enter Practice (no token needed to browse/enter) ───────────── + // The hub's three cards land on Pilots/Events/Timers pages; the Events page is the former + // picker, which renders Practice prominently. The Director is the page's own origin. + await page.getByRole('button', { name: /Events/ }).click(); await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ timeout: 15_000 }); @@ -136,3 +137,66 @@ test('RD drives a full basic sim race through the console UI', async ({ page }) const firstPos = await rows.first().locator('.pos .badge').textContent(); expect(firstPos?.trim()).toBe('1'); }); + +/** + * The home-hub navigation itself (#118): from the hub, each of the three cards opens its page and + * the breadcrumb's "Home" crumb returns to the hub; the Timers page renders the registry manager. + * No event is entered here — this exercises the app-level shell, not the workspace. + */ +test('home hub navigates to each page and back, with working breadcrumbs', async ({ page }) => { + await page.goto('/'); + + // The worker's Director is shared, so a prior spec may have left an active event — on load the + // shell would then resume into that workspace (#90). Wait for the shell to settle (either the + // hub's Pilots card or the workspace's Live-control nav appears), and if we resumed into the + // workspace use the brand/logo (a Home root, #118) to return to the hub. + const pilotsCard = page.getByRole('heading', { name: 'Pilots' }); + const liveNav = page.getByRole('button', { name: /Live control/ }); + await expect(pilotsCard.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await liveNav.isVisible().catch(() => false)) { + // Resumed into the workspace — the breadcrumb's Home crumb returns to the hub. + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + } + + // The hub: three cards. + await expect(pilotsCard).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Timers' })).toBeVisible(); + + // Home → Timers: the page renders the shared registry manager (the built-in Mock is listed), + // and the breadcrumb shows Home › Timers. + await page.getByRole('button', { name: /Timers/ }).click(); + await expect(page.getByRole('heading', { name: 'Timers', level: 1 })).toBeVisible(); + await expect(page.getByRole('list', { name: 'Configured timers' })).toBeVisible({ + timeout: 15_000 + }); + const crumbs = page.getByRole('navigation', { name: 'Breadcrumb' }); + await expect(crumbs.getByText('Timers')).toBeVisible(); + // Breadcrumb Home returns to the hub. + await crumbs.getByRole('button', { name: 'Home' }).click(); + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + + // Home → Pilots: the placeholder page with the "registration UI coming" note. + await page.getByRole('button', { name: /Pilots/ }).click(); + await expect(page.getByRole('heading', { name: 'Pilots', level: 1 })).toBeVisible(); + await expect(page.getByText(/registration UI/i)).toBeVisible({ timeout: 15_000 }); + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + await expect(page.getByRole('heading', { name: 'Timers' })).toBeVisible(); + + // Home → Events: the picker (former landing), reachable as a page now. + await page.getByRole('button', { name: /Events/ }).click(); + await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ + timeout: 15_000 + }); + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + await expect(page.getByRole('heading', { name: 'Pilots' })).toBeVisible(); +}); From 639cb4e46d7f9d3f49b53d7edef8d943f10a4234 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 03:20:19 +0000 Subject: [PATCH 086/362] Pilots page: registration form + reusable PilotManager (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the pilot directory UI: a reusable PilotManager (directory CRUD + the add/edit registration form) hosted on the Pilots page, mirroring how TimersPage hosts TimerManager — same full-trust-first → lazy-token write flow, same rowLead/listHeader/listFooter/rowChecked seams for the next slice's in-event Roster to layer per-event selection on top. The form: required callsign; name/phonetic/team text; VTX select; a color picker (#RRGGBB) with a clear control; a searchable CountrySelect (SVG flags via country-flag-icons — never emoji, which Windows renders as bare letters — US pinned top then alphabetical, storing the ISO alpha-2 code, clearable); MultiGP/Velocidrone id fields; and a key→value custom-attributes editor for the open attributes bag. Edit clears via null: buildUpdateRequest emits a minimal three-state diff — omit unchanged, null to clear (color/country especially, since '' fails the server's #hex / 2-letter validation), value to set; attributes is a full replacement. Wired through the new session.createPilot/updatePilot/deletePilot (protocol-client methods already existed; this adds the session seams + impl injection). Tests: unit-tested the form helpers (required callsign, color/country clear → null, attributes add/remove), CountrySelect (US-first-then-alpha order, SVG-not-emoji, search, clear), and the new session methods; a browser e2e pilot-lifecycle spec (add with callsign+name+country+color+attribute → edit clearing color+country → remove). Build/check/lint/test/contract green; existing e2e specs stay green. Also re-export VtxType from @gridfpv/types (the binding existed but the barrel had never listed it). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/package.json | 3 +- frontend/apps/rd-console/src/lib/countries.ts | 283 ++++++++ frontend/apps/rd-console/src/lib/flags.ts | 30 + frontend/apps/rd-console/src/lib/pilots.ts | 176 +++++ .../apps/rd-console/src/lib/session.svelte.ts | 51 ++ .../src/screens/CountrySelect.svelte | 316 +++++++++ .../src/screens/PilotManager.svelte | 628 ++++++++++++++++++ .../rd-console/src/screens/PilotsPage.svelte | 198 +----- .../rd-console/tests/CountrySelect.test.ts | 60 ++ .../rd-console/tests/PilotManager.test.ts | 173 +++++ .../apps/rd-console/tests/PilotsPage.test.ts | 16 +- frontend/apps/rd-console/tests/pilots.test.ts | 124 ++++ .../rd-console/tests/session.svelte.test.ts | 88 ++- frontend/apps/rd-console/tests/support.ts | 15 +- frontend/e2e/pilots.spec.ts | 110 +++ frontend/e2e/race.spec.ts | 5 +- frontend/package-lock.json | 9 +- frontend/packages/types/src/generated.ts | 1 + 18 files changed, 2107 insertions(+), 179 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/countries.ts create mode 100644 frontend/apps/rd-console/src/lib/flags.ts create mode 100644 frontend/apps/rd-console/src/lib/pilots.ts create mode 100644 frontend/apps/rd-console/src/screens/CountrySelect.svelte create mode 100644 frontend/apps/rd-console/src/screens/PilotManager.svelte create mode 100644 frontend/apps/rd-console/tests/CountrySelect.test.ts create mode 100644 frontend/apps/rd-console/tests/PilotManager.test.ts create mode 100644 frontend/apps/rd-console/tests/pilots.test.ts create mode 100644 frontend/e2e/pilots.spec.ts diff --git a/frontend/apps/rd-console/package.json b/frontend/apps/rd-console/package.json index cdbc378..38a868b 100644 --- a/frontend/apps/rd-console/package.json +++ b/frontend/apps/rd-console/package.json @@ -15,7 +15,8 @@ "dependencies": { "@gridfpv/components": "*", "@gridfpv/protocol-client": "*", - "@gridfpv/types": "*" + "@gridfpv/types": "*", + "country-flag-icons": "^1.6.17" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/frontend/apps/rd-console/src/lib/countries.ts b/frontend/apps/rd-console/src/lib/countries.ts new file mode 100644 index 0000000..e56a4f8 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/countries.ts @@ -0,0 +1,283 @@ +/** + * The bundled ISO 3166-1 alpha-2 country list (code + English name) backing {@link CountrySelect} + * (issue #74). Generated once from the SVG flag set shipped by `country-flag-icons` (every code + * here has a flag) paired with the browser/Node `Intl.DisplayNames` English region names, then + * frozen into source so the selector has a stable, dependency-light data set. Sorted alphabetically + * by name; the selector pins **United States** to the top itself (it does not reorder this list). + * + * The stored value on a {@link Pilot} is the **code** only (uppercase alpha-2); flags and names are + * derived from it. Do not hand-edit — regenerate from the same two sources if the set needs refresh. + */ + +/** One country: its ISO 3166-1 alpha-2 `code` (uppercase) and English display `name`. */ +export interface Country { + readonly code: string; + readonly name: string; +} + +/** Alpha-2 code of the United States — pinned to the top of {@link CountrySelect}. */ +export const US_CODE = 'US'; + +/** Every selectable country (code + name), sorted alphabetically by name. */ +export const COUNTRIES: readonly Country[] = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'AX', name: 'Åland Islands' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AG', name: 'Antigua & Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AC', name: 'Ascension Island' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia & Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BR', name: 'Brazil' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'VG', name: 'British Virgin Islands' }, + { code: 'BN', name: 'Brunei' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'IC', name: 'Canary Islands' }, + { code: 'CV', name: 'Cape Verde' }, + { code: 'BQ', name: 'Caribbean Netherlands' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CC', name: 'Cocos (Keeling) Islands' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo - Brazzaville' }, + { code: 'CD', name: 'Congo - Kinshasa' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'CI', name: 'Côte d’Ivoire' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CW', name: 'Curaçao' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czechia' }, + { code: 'DK', name: 'Denmark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'EU', name: 'European Union' }, + { code: 'FK', name: 'Falkland Islands' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GR', name: 'Greece' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GU', name: 'Guam' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HM', name: 'Heard & McDonald Islands' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HK', name: 'Hong Kong SAR China' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'XK', name: 'Kosovo' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MO', name: 'Macao SAR China' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar (Burma)' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NU', name: 'Niue' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'KP', name: 'North Korea' }, + { code: 'MK', name: 'North Macedonia' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PS', name: 'Palestinian Territories' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PN', name: 'Pitcairn Islands' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'XA', name: 'Pseudo-Accents' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RE', name: 'Réunion' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'São Tomé & Príncipe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SX', name: 'Sint Maarten' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'GS', name: 'South Georgia & South Sandwich Islands' }, + { code: 'KR', name: 'South Korea' }, + { code: 'SS', name: 'South Sudan' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'BL', name: 'St. Barthélemy' }, + { code: 'SH', name: 'St. Helena' }, + { code: 'KN', name: 'St. Kitts & Nevis' }, + { code: 'LC', name: 'St. Lucia' }, + { code: 'MF', name: 'St. Martin' }, + { code: 'PM', name: 'St. Pierre & Miquelon' }, + { code: 'VC', name: 'St. Vincent & Grenadines' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SJ', name: 'Svalbard & Jan Mayen' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syria' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad & Tobago' }, + { code: 'TA', name: 'Tristan da Cunha' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Türkiye' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TC', name: 'Turks & Caicos Islands' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'UM', name: 'U.S. Outlying Islands' }, + { code: 'VI', name: 'U.S. Virgin Islands' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'US', name: 'United States' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VA', name: 'Vatican City' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'WF', name: 'Wallis & Futuna' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' } +]; + +/** A `code → name` lookup for resolving a stored country code to its display name. */ +export const COUNTRY_NAME: Readonly> = Object.fromEntries( + COUNTRIES.map((c) => [c.code, c.name]) +); diff --git a/frontend/apps/rd-console/src/lib/flags.ts b/frontend/apps/rd-console/src/lib/flags.ts new file mode 100644 index 0000000..035d263 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/flags.ts @@ -0,0 +1,30 @@ +/** + * Flag SVG lookup for {@link CountrySelect} / the pilot directory (issue #74). + * + * We render **SVG** flags, never emoji: Windows has no flag-emoji font, so the unicode regional + * indicators fall back to bare letters ("US") there — unreadable on the very console the RD runs on + * a Windows laptop. `country-flag-icons/string/3x2` ships each flag as an inline SVG **string** + * keyed by ISO 3166-1 alpha-2 code, which we resolve here and the UI drops in with `{@html}`. + * + * The strings are static, self-contained `` markup from the dependency (no external + * fetch, no ``), so they theme/scale with the surrounding box and carry no scripting. + */ +import * as Flags from 'country-flag-icons/string/3x2'; + +/** The code → SVG-string map the dependency exports (a plain record once the namespace is read). */ +const FLAGS = Flags as unknown as Record; + +/** + * The inline SVG markup for a country `code` (ISO 3166-1 alpha-2, case-insensitive), or `undefined` + * if there is no flag for it. The returned string is a complete `` element the caller injects + * with `{@html}`. + */ +export function flagSvg(code: string | undefined): string | undefined { + if (!code) return undefined; + return FLAGS[code.toUpperCase()]; +} + +/** Whether a flag SVG exists for the given alpha-2 `code`. */ +export function hasFlag(code: string | undefined): boolean { + return flagSvg(code) !== undefined; +} diff --git a/frontend/apps/rd-console/src/lib/pilots.ts b/frontend/apps/rd-console/src/lib/pilots.ts new file mode 100644 index 0000000..f8c630d --- /dev/null +++ b/frontend/apps/rd-console/src/lib/pilots.ts @@ -0,0 +1,176 @@ +/** + * Pilot presentation + form helpers (issue #74). + * + * Pure mappers shared by the directory list and the add/edit form: the selectable VTX types, the + * Badge tone for a {@link VtxType}, and the **clear-via-null** diff that turns a form's working + * values into an {@link UpdatePilotRequest} (omit unchanged, `null` to clear, value to set). No I/O + * — the {@link Session} owns the protocol calls; these only shape data for the UI and the wire. + */ +import type { CreatePilotRequest, Pilot, UpdatePilotRequest, VtxType } from '@gridfpv/types'; + +/** The selectable VTX types in the form, in display order (the empty string is "None"). */ +export const VTX_TYPES: readonly VtxType[] = ['Analog', 'HDZero', 'DJI', 'Walksnail']; + +/** The Badge `tone` for each VTX type — a quiet, consistent palette across the directory. */ +const VTX_TONES: Record = { + Analog: 'warn', + HDZero: 'info', + DJI: 'accent', + Walksnail: 'success' +}; + +/** The Badge `tone` for a VTX type. */ +export function vtxTone(vtx: VtxType): 'accent' | 'info' | 'success' | 'warn' { + return VTX_TONES[vtx]; +} + +/** + * The editable shape the form holds while open — every field a string (the form binds plain inputs) + * plus the attributes bag. `vtx` is `''` for "None". The form diffs this against the pilot being + * edited to build the {@link UpdatePilotRequest}, or maps it straight to a {@link CreatePilotRequest}. + */ +export interface PilotFormValues { + callsign: string; + name: string; + phonetic: string; + team: string; + vtx: VtxType | ''; + color: string; + country: string; + multigp_id: string; + velocidrone_id: string; + attributes: Record; +} + +/** Blank form values for the **Add** flow. */ +export function emptyForm(): PilotFormValues { + return { + callsign: '', + name: '', + phonetic: '', + team: '', + vtx: '', + color: '', + country: '', + multigp_id: '', + velocidrone_id: '', + attributes: {} + }; +} + +/** Seed the form from an existing pilot for the **Edit** flow. */ +export function formFromPilot(p: Pilot): PilotFormValues { + return { + callsign: p.callsign, + name: p.name ?? '', + phonetic: p.phonetic ?? '', + team: p.team ?? '', + vtx: p.vtx_type ?? '', + color: p.color ?? '', + country: p.country ?? '', + multigp_id: p.multigp_id ?? '', + velocidrone_id: p.velocidrone_id ?? '', + attributes: { ...p.attributes } + }; +} + +/** Drop attribute rows with a blank key; trim keys (values are kept verbatim). */ +export function cleanAttributes(attrs: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(attrs)) { + const key = k.trim(); + if (key) out[key] = v; + } + return out; +} + +/** + * Build the **create** body from the form: the required callsign plus only the optional fields the + * user filled (a blank optional is simply omitted — `POST /pilots` treats absent as unset). The + * attributes bag is sent (empty `{}` is fine). + */ +export function buildCreateRequest(v: PilotFormValues): CreatePilotRequest { + const req: CreatePilotRequest = { + callsign: v.callsign.trim(), + attributes: cleanAttributes(v.attributes) + }; + const name = v.name.trim(); + if (name) req.name = name; + const phonetic = v.phonetic.trim(); + if (phonetic) req.phonetic = phonetic; + const team = v.team.trim(); + if (team) req.team = team; + if (v.vtx) req.vtx_type = v.vtx; + const color = v.color.trim(); + if (color) req.color = color; + const country = v.country.trim(); + if (country) req.country = country.toUpperCase(); + const multigp = v.multigp_id.trim(); + if (multigp) req.multigp_id = multigp; + const velocidrone = v.velocidrone_id.trim(); + if (velocidrone) req.velocidrone_id = velocidrone; + return req; +} + +/** + * Build the **update** body as a minimal three-state diff against the pilot being edited (#74): + * + * - **omit** a field whose form value still matches the pilot → leave it unchanged; + * - send a **`null`** when the user emptied a field that had a value → **clear** it (this is what + * makes `color`/`country` actually clear — sending `''` would fail the server's `#hex`/2-letter + * validation, so a cleared field *must* go as `null`); + * - send the **value** when the user changed it → set it. + * + * `callsign` is special: a present non-empty value replaces it; a blank one is omitted (the callsign + * is required and never cleared). `attributes` is a **full replacement** — sent whenever it differs. + */ +export function buildUpdateRequest(prev: Pilot, v: PilotFormValues): UpdatePilotRequest { + const req: UpdatePilotRequest = {}; + + const callsign = v.callsign.trim(); + if (callsign && callsign !== prev.callsign) req.callsign = callsign; + + // A text optional: omit if unchanged, `null` if cleared, value if set. + const diffText = (next: string, before: string | undefined): string | null | undefined => { + const trimmed = next.trim(); + const had = before ?? ''; + if (trimmed === had) return undefined; // unchanged + return trimmed === '' ? null : trimmed; // cleared → null, else set + }; + + const name = diffText(v.name, prev.name); + if (name !== undefined) req.name = name; + const phonetic = diffText(v.phonetic, prev.phonetic); + if (phonetic !== undefined) req.phonetic = phonetic; + const team = diffText(v.team, prev.team); + if (team !== undefined) req.team = team; + const color = diffText(v.color, prev.color); + if (color !== undefined) req.color = color; + const multigp = diffText(v.multigp_id, prev.multigp_id); + if (multigp !== undefined) req.multigp_id = multigp; + const velocidrone = diffText(v.velocidrone_id, prev.velocidrone_id); + if (velocidrone !== undefined) req.velocidrone_id = velocidrone; + + // Country: codes are uppercased before comparing/sending; cleared → null. + const nextCountry = v.country.trim().toUpperCase(); + const prevCountry = prev.country ?? ''; + if (nextCountry !== prevCountry) req.country = nextCountry === '' ? null : nextCountry; + + // VTX: a closed enum (or '' for none) — omit if unchanged, null if cleared, value if set. + const prevVtx = prev.vtx_type ?? ''; + if (v.vtx !== prevVtx) req.vtx_type = v.vtx === '' ? null : v.vtx; + + // Attributes: a full-replacement map — send it whenever the cleaned bag differs. + const nextAttrs = cleanAttributes(v.attributes); + if (!shallowEqualRecord(nextAttrs, prev.attributes)) req.attributes = nextAttrs; + + return req; +} + +/** Whether two `string → string` records have identical keys and values. */ +function shallowEqualRecord(a: Record, b: Record): boolean { + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) return false; + return ak.every((k) => a[k] === b[k]); +} diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index bcbce6c..d9418f5 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -49,6 +49,9 @@ import { setEventTimers, setPrimaryTimer, listPilots, + createPilot, + updatePilot, + deletePilot, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -58,16 +61,19 @@ import type { Command, CommandAck, CreateEventRequest, + CreatePilotRequest, CreateTimerRequest, EventMeta, HeatId, HeatResult, LiveRaceState, Pilot, + PilotId, ProjectionBody, Scope, Timer, TimerId, + UpdatePilotRequest, UpdateTimerRequest } from '@gridfpv/types'; @@ -210,6 +216,9 @@ export class Session { #setEventTimersImpl: typeof setEventTimers; #setPrimaryTimerImpl: typeof setPrimaryTimer; #listPilotsImpl: typeof listPilots; + #createPilotImpl: typeof createPilot; + #updatePilotImpl: typeof updatePilot; + #deletePilotImpl: typeof deletePilot; constructor(opts?: { connectImpl?: typeof connect; @@ -225,6 +234,9 @@ export class Session { setEventTimersImpl?: typeof setEventTimers; setPrimaryTimerImpl?: typeof setPrimaryTimer; listPilotsImpl?: typeof listPilots; + createPilotImpl?: typeof createPilot; + updatePilotImpl?: typeof updatePilot; + deletePilotImpl?: typeof deletePilot; baseUrl?: string; autoRestore?: boolean; }) { @@ -241,6 +253,9 @@ export class Session { this.#setEventTimersImpl = opts?.setEventTimersImpl ?? setEventTimers; this.#setPrimaryTimerImpl = opts?.setPrimaryTimerImpl ?? setPrimaryTimer; this.#listPilotsImpl = opts?.listPilotsImpl ?? listPilots; + this.#createPilotImpl = opts?.createPilotImpl ?? createPilot; + this.#updatePilotImpl = opts?.updatePilotImpl ?? updatePilot; + this.#deletePilotImpl = opts?.deletePilotImpl ?? deletePilot; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -320,6 +335,42 @@ export class Session { return this.#listPilotsImpl(this.baseUrl, { token: this.#token }); } + /** + * Create a directory pilot (`POST /pilots`) — issue #74. RD-gated, full-trust first like the + * timer writes: attempted tokenless, and only if the Director answers 401/403 does the lazy + * prompt fire once and the create retry. The `callsign` is required (server **400** on blank); + * the id is auto-generated. Returns the new {@link Pilot}, `undefined` on a cancelled prompt, or + * throws on a non-auth failure (a bad hex `color` / `country` is a **400** the form surfaces). + */ + createPilot(request: CreatePilotRequest): Promise { + return this.#privilegedWrite((token) => this.#createPilotImpl(this.baseUrl, request, token)); + } + + /** + * Edit a directory pilot (`PUT /pilots/{id}`) — issue #74. RD-gated. The request carries only the + * fields that changed: an omitted optional field is left unchanged, a present **`null`** clears it + * (the way the form clears `color`/`country`), and a present value sets it. Returns the updated + * {@link Pilot}, `undefined` on a cancelled prompt, or throws on a non-auth failure. + */ + updatePilot(id: PilotId, request: UpdatePilotRequest): Promise { + return this.#privilegedWrite((token) => + this.#updatePilotImpl(this.baseUrl, id, request, token) + ); + } + + /** + * Remove a directory pilot (`DELETE /pilots/{id}`) — issue #74. RD-gated. Resolves to `true` once + * the delete succeeds, `undefined` on a cancelled token prompt, or throws on any other failure + * (an unknown id is a **404**). (`true` lets callers tell success from a cancelled prompt — the + * `DELETE` has no body, mirroring {@link deleteTimer}.) + */ + deletePilot(id: PilotId): Promise { + return this.#privilegedWrite(async (token) => { + await this.#deletePilotImpl(this.baseUrl, id, token); + return true as const; + }); + } + /** * Run a control-gated write with the full-trust-then-lazy-prompt retry: call `attempt` with * the currently-held token; if it fails for **auth** and no token is held yet, prompt once and diff --git a/frontend/apps/rd-console/src/screens/CountrySelect.svelte b/frontend/apps/rd-console/src/screens/CountrySelect.svelte new file mode 100644 index 0000000..1cb9215 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/CountrySelect.svelte @@ -0,0 +1,316 @@ + + + + +
      + + + {#if open} +
      + +
        + {#each filtered as c (c.code)} +
      • + +
      • + {:else} +
      • No matches
      • + {/each} +
      +
      + {/if} +
      + + diff --git a/frontend/apps/rd-console/src/screens/PilotManager.svelte b/frontend/apps/rd-console/src/screens/PilotManager.svelte new file mode 100644 index 0000000..8bac835 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/PilotManager.svelte @@ -0,0 +1,628 @@ + + +
      + {#if loadState.kind === 'loading'} +
      + + Loading pilots… +
      + {:else if loadState.kind === 'error'} +
      +

      Couldn't load the pilots.

      + {loadState.message} + +
      + {:else if loadState.pilots.length === 0} +
      +

      No pilots in the directory yet.

      +

      Add one to start building your roster.

      +
      + {:else} + {@render listHeader?.()} +
        + {#each loadState.pilots as pilot (pilot.id)} +
      • + {@render rowLead?.(pilot)} + {#if pilot.color} + + {/if} +
        +
        + {#if pilot.country && flagSvg(pilot.country)} + + + + {@html flagSvg(pilot.country)} + + {/if} + {pilot.callsign} + {#if pilot.team}{pilot.team}{/if} + {#if pilot.vtx_type}{pilot.vtx_type}{/if} +
        + {#if pilot.name}{pilot.name}{/if} +
        +
        + + +
        +
      • + {/each} +
      + {@render listFooter?.()} + {/if} +
      + + + +
      + + + + +
      + + + + + + +
      + +
      + + + + + + +
      + +
      + +
      + (form.color = (e.currentTarget as HTMLInputElement).value)} + /> + {form.color || '—'} + {#if form.color} + + {/if} +
      +
      + + + +
      + +
      + + + + + + +
      + +
      + Custom attributes +

      Anything else — insurance #, FAA/FCC license, bib, sponsor…

      + {#if attrRows.length > 0} +
        + {#each attrRows as row, i (i)} +
      • + + + +
      • + {/each} +
      + {/if} + +
      +
      + {#snippet footer()} + + + {/snippet} +
      + + + (confirming = undefined)} + title="Remove pilot" +> +

      + Remove {confirming?.callsign} from the directory? Any event that rostered them keeps + running; this only removes the directory entry. +

      + {#snippet footer()} + + + {/snippet} +
      + + diff --git a/frontend/apps/rd-console/src/screens/PilotsPage.svelte b/frontend/apps/rd-console/src/screens/PilotsPage.svelte index 5a4703e..0410715 100644 --- a/frontend/apps/rd-console/src/screens/PilotsPage.svelte +++ b/frontend/apps/rd-console/src/screens/PilotsPage.svelte @@ -1,43 +1,25 @@ @@ -46,47 +28,20 @@
      -

      Pilots

      -

      - The application-level pilot directory — maintained once, rostered per event. -

      +
      +

      Pilots

      +

      + The application-level pilot directory — maintained once here, rostered per event. Each + pilot needs only a callsign; the rest (team, country, color, VTX, IDs, + custom attributes) is optional. +

      +
      +
      -
      - {#if loadState.kind === 'loading'} -
      - - Loading pilots… -
      - {:else if loadState.kind === 'error'} -
      -

      Couldn't reach the Director.

      - {loadState.message} - -
      - {:else if loadState.pilots.length === 0} -
      -

      No pilots in the directory yet.

      -
      - {:else} -
        - {#each loadState.pilots as pilot (pilot.id)} -
      • - {pilot.callsign} - {#if pilot.name}{pilot.name}{/if} - {#if pilot.team}{pilot.team}{/if} -
      • - {/each} -
      - {/if} - - -

      - Coming soon - Pilot registration UI (add, edit, remove, cloud-pull import) lands in the next slice. -

      +
      +
      @@ -108,9 +63,16 @@ gap: var(--gf-space-5); } .page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--gf-space-4); + } + .page-titles { display: flex; flex-direction: column; gap: var(--gf-space-2); + min-width: 0; } .page-title { margin: 0; @@ -119,107 +81,15 @@ } .lead { margin: 0; + max-width: 38rem; color: var(--gf-text-muted); font-size: var(--gf-font-size-sm); } - .body { - display: flex; - flex-direction: column; - gap: var(--gf-space-4); - padding: var(--gf-space-2); - } - .state-msg { - display: flex; - align-items: center; - gap: var(--gf-space-3); - color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); - padding: var(--gf-space-4) 0; - } - .spinner { - width: 1.1rem; - height: 1.1rem; - border-radius: 50%; - border: 2px solid var(--gf-border); - border-top-color: var(--gf-accent); - animation: spin 0.7s linear infinite; - } - @keyframes spin { - to { - transform: rotate(360deg); - } - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation-duration: 2s; - } - } - .state-error { - display: flex; - flex-direction: column; - gap: var(--gf-space-3); - align-items: flex-start; - } - .state-error p { - margin: 0; + .lead strong { + color: var(--gf-text-secondary); font-weight: var(--gf-font-weight-semibold); } - .state-error code { - font-size: var(--gf-font-size-xs); - color: var(--gf-danger); - word-break: break-all; - } - .empty { - padding: var(--gf-space-4) 0; - color: var(--gf-text-muted); - } - .empty p { - margin: 0; - } - .pilot-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--gf-space-2); - } - .pilot-row { - display: flex; - align-items: center; - gap: var(--gf-space-3); - padding: var(--gf-space-3) var(--gf-space-4); - border: 1px solid var(--gf-border-subtle); - border-radius: var(--gf-radius-md); - background: var(--gf-surface); - } - .callsign { - font-size: var(--gf-font-size-md); - font-weight: var(--gf-font-weight-semibold); - letter-spacing: var(--gf-tracking-tight); - } - .real-name { - color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); - } - .coming { - margin: 0; - display: flex; - align-items: center; - gap: var(--gf-space-2); - flex-wrap: wrap; - padding: var(--gf-space-3) var(--gf-space-4); - border: 1px dashed var(--gf-border); - border-radius: var(--gf-radius-md); - background: var(--gf-surface-alt); - color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); - } - .coming-tag { - text-transform: uppercase; - letter-spacing: var(--gf-tracking-caps); - font-size: var(--gf-font-size-2xs); - font-weight: var(--gf-font-weight-semibold); - color: var(--gf-accent); + .manager-wrap { + padding: var(--gf-space-2); } diff --git a/frontend/apps/rd-console/tests/CountrySelect.test.ts b/frontend/apps/rd-console/tests/CountrySelect.test.ts new file mode 100644 index 0000000..5ff09dc --- /dev/null +++ b/frontend/apps/rd-console/tests/CountrySelect.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import CountrySelect from '../src/screens/CountrySelect.svelte'; +import { COUNTRIES, US_CODE } from '../src/lib/countries.js'; + +describe('CountrySelect (#74)', () => { + it('lists United States first, then the rest alphabetically by name', async () => { + render(CountrySelect, { value: '' }); + await fireEvent.click(screen.getByRole('button', { name: 'Country' })); + + const listbox = screen.getByRole('listbox', { name: 'Countries' }); + const options = within(listbox).getAllByRole('option'); + // US is pinned to the very top. + expect(options[0]).toHaveTextContent('United States'); + // The remainder are alphabetical: the second option is the first country (by name) + // that is not the US — i.e. the head of the pre-sorted list minus US. + const restByName = COUNTRIES.filter((c) => c.code !== US_CODE); + expect(options[1]).toHaveTextContent(restByName[0].name); + expect(options[2]).toHaveTextContent(restByName[1].name); + }); + + it('renders SVG flags, never emoji (Windows shows emoji as bare letters)', async () => { + const { container } = render(CountrySelect, { value: '' }); + await fireEvent.click(screen.getByRole('button', { name: 'Country' })); + // Each option carries an inline flag — not a text emoji. + expect(container.querySelector('.cs-option svg')).not.toBeNull(); + }); + + it('filters by name or code as the user searches', async () => { + render(CountrySelect, { value: '' }); + await fireEvent.click(screen.getByRole('button', { name: 'Country' })); + + const search = screen.getByRole('textbox', { name: 'Search countries' }); + await fireEvent.input(search, { target: { value: 'german' } }); + const listbox = screen.getByRole('listbox', { name: 'Countries' }); + const options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Germany'); + }); + + it('stores the alpha-2 code on selection (reflected in the trigger)', async () => { + render(CountrySelect, { value: '' }); + await fireEvent.click(screen.getByRole('button', { name: 'Country' })); + const search = screen.getByRole('textbox', { name: 'Search countries' }); + await fireEvent.input(search, { target: { value: 'United States' } }); + await fireEvent.click(screen.getByRole('option', { name: /United States/ })); + // The trigger now shows the selected country's name + its stored code. + const trigger = screen.getByRole('button', { name: 'Country' }); + expect(trigger).toHaveTextContent('United States'); + expect(within(trigger).getByText('US')).toBeInTheDocument(); + }); + + it('clears the selection back to the placeholder', async () => { + render(CountrySelect, { value: 'US', placeholder: 'Select a country…' }); + // The selected trigger shows a Clear control; clicking it returns to the placeholder. + await fireEvent.click(screen.getByRole('button', { name: 'Clear country' })); + expect(screen.getByText('Select a country…')).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/PilotManager.test.ts b/frontend/apps/rd-console/tests/PilotManager.test.ts new file mode 100644 index 0000000..c97e073 --- /dev/null +++ b/frontend/apps/rd-console/tests/PilotManager.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { Pilot } from '@gridfpv/types'; +import PilotManager from '../src/screens/PilotManager.svelte'; +import { makeTestSession } from './support.js'; + +const ACE: Pilot = { + id: 'p1', + callsign: 'Ace', + name: 'Alice', + color: '#ff0000', + country: 'US', + attributes: { bib: '7' } +}; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', attributes: {} }; + +/** Open the Add form via the manager's exported `openAdd()` (the page button calls it). */ +async function openAdd(manager: { openAdd: () => void }) { + manager.openAdd(); + await screen.findByRole('form', { name: 'Add pilot' }); +} + +describe('PilotManager (#74)', () => { + it('lists the directory with callsign, name, and per-row controls', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); + render(PilotManager, { session }); + + await screen.findByText('Ace'); + expect(screen.getByText('Alice')).toBeInTheDocument(); + const list = screen.getByRole('list', { name: 'Pilot directory' }); + expect(within(list).getAllByRole('listitem')).toHaveLength(2); + }); + + it('shows the empty state when the directory has no pilots', async () => { + const listPilotsImpl = vi.fn(async () => []); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); + render(PilotManager, { session }); + await screen.findByText(/No pilots in the directory yet/i); + }); + + it('requires a non-empty callsign before it will create', async () => { + const listPilotsImpl = vi.fn(async () => []); + const createPilotImpl = vi.fn(async () => ACE); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, createPilotImpl }); + const { component } = render(PilotManager, { session }); + await screen.findByText(/No pilots/i); + + await openAdd(component as unknown as { openAdd: () => void }); + // The submit button is disabled while the callsign is blank. + const submit = screen.getByRole('button', { name: 'Add pilot' }); + expect(submit).toBeDisabled(); + expect(createPilotImpl).not.toHaveBeenCalled(); + }); + + it('creates a pilot with callsign + name + country + color + a custom attribute', async () => { + let calls = 0; + const created: Pilot = { + id: 'p9', + callsign: 'Neo', + name: 'Thomas', + country: 'US', + color: '#00ff00', + attributes: { sponsor: 'Acme' } + }; + const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); + const createPilotImpl = vi.fn(async () => created); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, createPilotImpl }); + const { component } = render(PilotManager, { session }); + await screen.findByText(/No pilots/i); + await openAdd(component as unknown as { openAdd: () => void }); + + await fireEvent.input(screen.getByLabelText('Callsign'), { target: { value: 'Neo' } }); + await fireEvent.input(screen.getByLabelText('Real name'), { target: { value: 'Thomas' } }); + await fireEvent.input(screen.getByLabelText('Color'), { target: { value: '#00ff00' } }); + + // Add a custom attribute row, fill it. + await fireEvent.click(screen.getByRole('button', { name: '+ Add attribute' })); + await fireEvent.input(screen.getByLabelText('Attribute 1 key'), { + target: { value: 'sponsor' } + }); + await fireEvent.input(screen.getByLabelText('Attribute 1 value'), { + target: { value: 'Acme' } + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); + + await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); + expect(createPilotImpl).toHaveBeenCalledWith( + 'http://d.local', + expect.objectContaining({ + callsign: 'Neo', + name: 'Thomas', + color: '#00ff00', + attributes: { sponsor: 'Acme' } + }), + 'tok' + ); + await screen.findByText('Neo'); + }); + + it('removes an attribute row so it is omitted from the create body', async () => { + const listPilotsImpl = vi.fn(async () => []); + const createPilotImpl = vi.fn(async () => ACE); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, createPilotImpl }); + const { component } = render(PilotManager, { session }); + await screen.findByText(/No pilots/i); + await openAdd(component as unknown as { openAdd: () => void }); + + await fireEvent.input(screen.getByLabelText('Callsign'), { target: { value: 'Ace' } }); + await fireEvent.click(screen.getByRole('button', { name: '+ Add attribute' })); + await fireEvent.input(screen.getByLabelText('Attribute 1 key'), { target: { value: 'bib' } }); + await fireEvent.input(screen.getByLabelText('Attribute 1 value'), { target: { value: '7' } }); + // Remove it again. + await fireEvent.click(screen.getByRole('button', { name: 'Remove attribute 1' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); + + await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); + expect(createPilotImpl).toHaveBeenCalledWith( + 'http://d.local', + expect.objectContaining({ callsign: 'Ace', attributes: {} }), + 'tok' + ); + }); + + it('edits a pilot and CLEARS color + country, sending null for each', async () => { + const listPilotsImpl = vi.fn(async () => [ACE]); + const updatePilotImpl = vi.fn(async () => ({ ...ACE, color: undefined, country: undefined })); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, updatePilotImpl }); + render(PilotManager, { session }); + + await screen.findByText('Ace'); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await screen.findByRole('form', { name: 'Edit pilot' }); + + // Clear color via its Clear button, clear country via the CountrySelect clear control. + await fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + await fireEvent.click(screen.getByRole('button', { name: 'Clear country' })); + + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(updatePilotImpl).toHaveBeenCalledTimes(1)); + // The cleared fields go as explicit null; unchanged fields are omitted. + expect(updatePilotImpl).toHaveBeenCalledWith( + 'http://d.local', + 'p1', + { color: null, country: null }, + 'tok' + ); + }); + + it('removes a pilot after a confirm step', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const deletePilotImpl = vi.fn(async () => undefined as unknown as void); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, deletePilotImpl }); + render(PilotManager, { session }); + + await screen.findByText('Ace'); + const list = screen.getByRole('list', { name: 'Pilot directory' }); + const aceRow = within(list).getAllByRole('listitem')[0]; + await fireEvent.click(within(aceRow).getByRole('button', { name: 'Remove' })); + + // A confirm dialog appears; confirm it. + await screen.findByText(/Remove pilot/i); + const dialogs = screen.getAllByRole('button', { name: 'Remove' }); + // The confirm dialog's Remove is the last-rendered one. + await fireEvent.click(dialogs[dialogs.length - 1]); + + await waitFor(() => expect(deletePilotImpl).toHaveBeenCalledTimes(1)); + expect(deletePilotImpl).toHaveBeenCalledWith('http://d.local', 'p1', 'tok'); + }); +}); diff --git a/frontend/apps/rd-console/tests/PilotsPage.test.ts b/frontend/apps/rd-console/tests/PilotsPage.test.ts index 1ce88d7..dac23e1 100644 --- a/frontend/apps/rd-console/tests/PilotsPage.test.ts +++ b/frontend/apps/rd-console/tests/PilotsPage.test.ts @@ -8,23 +8,23 @@ import { makeTestSession } from './support.js'; const noop = () => {}; const PILOTS: Pilot[] = [ - { id: 'p1', callsign: 'Ace', name: 'Alice', attributes: {} }, + { id: 'p1', callsign: 'Ace', name: 'Alice', country: 'US', attributes: {} }, { id: 'p2', callsign: 'Bee', attributes: {} } ]; -describe('PilotsPage (placeholder for #74)', () => { - it('lists the directory callsigns read-only and notes the registration UI is coming', async () => { +describe('PilotsPage (#74) — hosts the shared PilotManager', () => { + it('lists the directory and exposes Add / Edit / Remove controls', async () => { const listPilotsImpl = vi.fn(async () => PILOTS); const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); render(PilotsPage, { session, onhome: noop }); - // The read-only callsigns appear. await screen.findByText('Ace'); expect(screen.getByText('Bee')).toBeInTheDocument(); - // The "coming" note seam for the next slice. - expect(screen.getByText(/registration UI/i)).toBeInTheDocument(); - // No management controls in this slice. - expect(screen.queryByRole('button', { name: /Add pilot/i })).toBeNull(); + // The page's primary action opens the add form. + expect(screen.getByRole('button', { name: '+ Add pilot' })).toBeInTheDocument(); + // Per-row management controls exist. + expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: 'Remove' })).toHaveLength(2); }); it('surfaces a read error with a retry', async () => { diff --git a/frontend/apps/rd-console/tests/pilots.test.ts b/frontend/apps/rd-console/tests/pilots.test.ts new file mode 100644 index 0000000..c05bf1b --- /dev/null +++ b/frontend/apps/rd-console/tests/pilots.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { Pilot } from '@gridfpv/types'; +import { + buildCreateRequest, + buildUpdateRequest, + cleanAttributes, + emptyForm, + formFromPilot, + type PilotFormValues +} from '../src/lib/pilots.js'; + +/** A pilot with every field populated — the baseline for the edit/diff tests. */ +const FULL: Pilot = { + id: 'p1', + callsign: 'Ace', + name: 'Alice', + phonetic: 'AYSS', + team: 'Red', + color: '#FF0000', + country: 'US', + vtx_type: 'DJI', + multigp_id: 'mg-1', + velocidrone_id: 'vd-1', + attributes: { bib: '7' } +}; + +describe('buildCreateRequest', () => { + it('sends only the callsign + filled optionals (blank optionals omitted)', () => { + const v = emptyForm(); + v.callsign = ' Ace '; + v.name = 'Alice'; + v.country = 'us'; // lower-case input → uppercased + const req = buildCreateRequest(v); + expect(req).toEqual({ callsign: 'Ace', name: 'Alice', country: 'US', attributes: {} }); + // Untouched optionals are absent (not empty strings). + expect(req).not.toHaveProperty('team'); + expect(req).not.toHaveProperty('color'); + expect(req).not.toHaveProperty('vtx_type'); + }); + + it('carries the cleaned attributes bag', () => { + const v = emptyForm(); + v.callsign = 'Ace'; + v.attributes = { ' license ': 'FAA-9', '': 'dropped' }; + const req = buildCreateRequest(v); + expect(req.attributes).toEqual({ license: 'FAA-9' }); + }); +}); + +describe('buildUpdateRequest — clear-via-null', () => { + function valuesFrom(p: Pilot): PilotFormValues { + return formFromPilot(p); + } + + it('omits every field when nothing changed', () => { + const req = buildUpdateRequest(FULL, valuesFrom(FULL)); + expect(req).toEqual({}); + }); + + it('emits null for color and country when the user clears them', () => { + const v = valuesFrom(FULL); + v.color = ''; + v.country = ''; + const req = buildUpdateRequest(FULL, v); + expect(req.color).toBeNull(); + expect(req.country).toBeNull(); + // Unchanged fields stay omitted. + expect(req).not.toHaveProperty('name'); + expect(req).not.toHaveProperty('team'); + }); + + it('sets a changed value and leaves others untouched', () => { + const v = valuesFrom(FULL); + v.name = 'Alicia'; + const req = buildUpdateRequest(FULL, v); + expect(req).toEqual({ name: 'Alicia' }); + }); + + it('clears the VTX type with null when set to None', () => { + const v = valuesFrom(FULL); + v.vtx = ''; + const req = buildUpdateRequest(FULL, v); + expect(req.vtx_type).toBeNull(); + }); + + it('uppercases a changed country code and only sends it when it differs', () => { + const v = valuesFrom(FULL); + v.country = 'gb'; + expect(buildUpdateRequest(FULL, v).country).toBe('GB'); + // Same code in different case is NOT a change. + const same = valuesFrom(FULL); + same.country = 'us'; + expect(buildUpdateRequest(FULL, same)).not.toHaveProperty('country'); + }); + + it('sends the full attributes map only when it differs', () => { + const unchanged = valuesFrom(FULL); + expect(buildUpdateRequest(FULL, unchanged)).not.toHaveProperty('attributes'); + + const added = valuesFrom(FULL); + added.attributes = { bib: '7', sponsor: 'Acme' }; + expect(buildUpdateRequest(FULL, added).attributes).toEqual({ bib: '7', sponsor: 'Acme' }); + + const cleared = valuesFrom(FULL); + cleared.attributes = {}; + expect(buildUpdateRequest(FULL, cleared).attributes).toEqual({}); + }); + + it('replaces the callsign when changed and never clears it when blanked', () => { + const changed = valuesFrom(FULL); + changed.callsign = 'Neo'; + expect(buildUpdateRequest(FULL, changed).callsign).toBe('Neo'); + + const blanked = valuesFrom(FULL); + blanked.callsign = ' '; + expect(buildUpdateRequest(FULL, blanked)).not.toHaveProperty('callsign'); + }); +}); + +describe('cleanAttributes', () => { + it('trims keys and drops blank ones', () => { + expect(cleanAttributes({ ' a ': '1', '': '2', b: '3' })).toEqual({ a: '1', b: '3' }); + }); +}); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 3cda1d2..e31bf53 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; -import type { CommandAck, EventMeta, Timer } from '@gridfpv/types'; +import type { CommandAck, EventMeta, Pilot, Timer } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; import { liveRunning, okAck, failAck } from './fixtures.js'; @@ -717,4 +717,90 @@ describe('Session', () => { ); }); }); + + describe('pilots (#74)', () => { + const ACE: Pilot = { id: 'p1', callsign: 'Ace', attributes: {} }; + + function pilotSession(overrides?: { + listPilotsImpl?: ReturnType; + createPilotImpl?: ReturnType; + updatePilotImpl?: ReturnType; + deletePilotImpl?: ReturnType; + }) { + const { connect } = mockConnect(connecting); + const controlFactory = vi.fn(() => ({ + baseUrl: 'http://d.local', + sendCommand: vi.fn(async () => okAck) + })); + return new Session({ + connectImpl: connect, + controlFactory, + baseUrl: 'http://d.local', + autoRestore: false, + ...overrides + }); + } + + it('listPilots reads the directory open (no token)', async () => { + const listPilotsImpl = vi.fn(async () => [ACE]); + const session = pilotSession({ listPilotsImpl }); + const pilots = await session.listPilots(); + expect(pilots).toEqual([ACE]); + expect(listPilotsImpl).toHaveBeenCalledWith('http://d.local', { token: undefined }); + }); + + it('createPilot returns the new pilot (full-trust, no token)', async () => { + const createPilotImpl = vi.fn(async () => ACE); + const session = pilotSession({ createPilotImpl }); + const req = { callsign: 'Ace', attributes: {} } as const; + const result = await session.createPilot(req); + expect(result).toEqual(ACE); + expect(createPilotImpl).toHaveBeenCalledWith('http://d.local', req, undefined); + }); + + it('createPilot prompts then retries once on an auth (401) failure', async () => { + const createPilotImpl = vi + .fn() + .mockRejectedValueOnce(new Error('POST /pilots failed: HTTP 401')) + .mockResolvedValueOnce(ACE); + const session = pilotSession({ createPilotImpl }); + session.setTokenProvider(async () => 'tok'); + const result = await session.createPilot({ callsign: 'Ace', attributes: {} }); + expect(result).toEqual(ACE); + expect(createPilotImpl).toHaveBeenCalledTimes(2); + expect(createPilotImpl).toHaveBeenLastCalledWith('http://d.local', expect.anything(), 'tok'); + }); + + it('createPilot resolves undefined when the auth prompt is cancelled', async () => { + const createPilotImpl = vi.fn().mockRejectedValue(new Error('POST /pilots failed: HTTP 401')); + const session = pilotSession({ createPilotImpl }); + session.setTokenProvider(async () => undefined); + const result = await session.createPilot({ callsign: 'X', attributes: {} }); + expect(result).toBeUndefined(); + expect(createPilotImpl).toHaveBeenCalledTimes(1); + }); + + it('updatePilot passes the clear-via-null diff through verbatim', async () => { + const updated: Pilot = { ...ACE, name: 'Alice' }; + const updatePilotImpl = vi.fn(async () => updated); + const session = pilotSession({ updatePilotImpl }); + // A representative diff: set name, clear color/country with null, leave the rest absent. + const req = { name: 'Alice', color: null, country: null } as const; + const result = await session.updatePilot('p1', req); + expect(result).toEqual(updated); + expect(updatePilotImpl).toHaveBeenCalledWith('http://d.local', 'p1', req, undefined); + }); + + it('deletePilot resolves true on success and re-throws a non-auth (404) failure', async () => { + const deletePilotImpl = vi.fn(async () => undefined as unknown as void); + const session = pilotSession({ deletePilotImpl }); + await expect(session.deletePilot('p1')).resolves.toBe(true); + expect(deletePilotImpl).toHaveBeenCalledWith('http://d.local', 'p1', undefined); + + const failing = pilotSession({ + deletePilotImpl: vi.fn().mockRejectedValue(new Error('DELETE /pilots/p1 failed: HTTP 404')) + }); + await expect(failing.deletePilot('p1')).rejects.toThrow(/404/); + }); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 2c5b786..17251ca 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -14,7 +14,10 @@ import type { deleteTimer, setEventTimers, setPrimaryTimer, - listPilots + listPilots, + createPilot, + updatePilot, + deletePilot } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -39,6 +42,10 @@ export interface TimerImpls { setPrimaryTimerImpl?: typeof setPrimaryTimer; /** The app-level pilot directory read (issue #74) — backs the hub + Pilots page tests. */ listPilotsImpl?: typeof listPilots; + /** The pilot-directory write seams (issue #74) — back the PilotManager tests. */ + createPilotImpl?: typeof createPilot; + updatePilotImpl?: typeof updatePilot; + deletePilotImpl?: typeof deletePilot; } export interface TestSession { @@ -90,7 +97,11 @@ export function makeTestSession( deleteTimerImpl: opts?.deleteTimerImpl, setEventTimersImpl: opts?.setEventTimersImpl, setPrimaryTimerImpl: opts?.setPrimaryTimerImpl, - listPilotsImpl: opts?.listPilotsImpl + listPilotsImpl: opts?.listPilotsImpl, + // Pilot-directory write seams (issue #74): inert unless a test overrides them. + createPilotImpl: opts?.createPilotImpl, + updatePilotImpl: opts?.updatePilotImpl, + deletePilotImpl: opts?.deletePilotImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/e2e/pilots.spec.ts b/frontend/e2e/pilots.spec.ts new file mode 100644 index 0000000..74aa529 --- /dev/null +++ b/frontend/e2e/pilots.spec.ts @@ -0,0 +1,110 @@ +/** + * Pilot directory lifecycle through the console UI (#74) — the deliverable proof for the pilot + * registration slice. + * + * A person opens the console, lands on the **home hub**, opens the **Pilots** page, and drives the + * full directory CRUD against a **real** Director (open / no token, full-trust by default): **add** + * a pilot (callsign + real name + country + color + a custom attribute) and see it listed; **edit** + * it and **clear** the color and country, confirming they actually clear (the clear-via-`null` + * wiring); then **remove** it. Every step is a real click/input in headless chromium on the real + * `POST/PUT/DELETE /pilots` path — nothing mocked. + * + * Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack dump + * (browser console, page errors, the Director's server log). The worker's Director is shared, so + * this spec uses a unique callsign and cleans up after itself (the final remove) to stay isolated. + */ +import { expect, test } from './observability.js'; + +const CALLSIGN = `E2E-Pilot-${Date.now()}`; + +test('RD adds, edits (clearing color + country), and removes a directory pilot', async ({ + page +}) => { + await page.goto('/'); + + // The worker's Director may have an active event left by a prior spec (#90): if we resumed into + // the workspace, use the breadcrumb Home to get back to the hub. + const pilotsCard = page.getByRole('heading', { name: 'Pilots' }); + const liveNav = page.getByRole('button', { name: /Live control/ }); + await expect(pilotsCard.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await liveNav.isVisible().catch(() => false)) { + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + } + + // ── Hub → Pilots page ──────────────────────────────────────────────────────────────── + await page.getByRole('button', { name: /Pilots/ }).click(); + await expect(page.getByRole('heading', { name: 'Pilots', level: 1 })).toBeVisible({ + timeout: 15_000 + }); + + // ── Add a pilot: callsign + real name + country + color + a custom attribute ─────────── + await page.getByRole('button', { name: '+ Add pilot' }).click(); + const addForm = page.getByRole('form', { name: 'Add pilot' }); + await expect(addForm).toBeVisible(); + + await addForm.getByLabel('Callsign').fill(CALLSIGN); + await addForm.getByLabel('Real name').fill('Ada Lovelace'); + // Color (a native color input writes #rrggbb). + await addForm.getByLabel('Color').fill('#ff8800'); + + // Country: open the searchable selector, search, pick United States. + await addForm.getByRole('button', { name: 'Country', exact: true }).click(); + await addForm.getByRole('textbox', { name: 'Search countries' }).fill('United States'); + await addForm.getByRole('option', { name: /United States/ }).click(); + + // A custom attribute row. + await addForm.getByRole('button', { name: '+ Add attribute' }).click(); + await addForm.getByLabel('Attribute 1 key').fill('bib'); + await addForm.getByLabel('Attribute 1 value').fill('42'); + + // Submit (open Director — no token prompt). `exact` so it picks the dialog's submit, not the + // page header's "+ Add pilot". + await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // It appears in the directory with its callsign + name, and a US flag is rendered. + const list = page.getByRole('list', { name: 'Pilot directory' }); + const row = list.getByRole('listitem').filter({ hasText: CALLSIGN }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row.getByText('Ada Lovelace')).toBeVisible(); + // SVG flag (never emoji) is present on the row. + await expect(row.locator('.flag svg')).toBeVisible(); + // The color swatch reflects the chosen color. + await expect(row.locator('.color-swatch')).toBeVisible(); + + // ── Edit: clear the color and the country ────────────────────────────────────────────── + await row.getByRole('button', { name: 'Edit' }).click(); + const editForm = page.getByRole('form', { name: 'Edit pilot' }); + await expect(editForm).toBeVisible(); + // The country selector shows the previously-set United States. + await expect(editForm.getByRole('button', { name: 'Country', exact: true })).toContainText( + 'United States' + ); + + // Clear the color (its inline Clear button) and the country (the selector's clear control). + await editForm.getByRole('button', { name: 'Clear', exact: true }).click(); + await editForm.getByRole('button', { name: 'Clear country' }).click(); + await page.getByRole('button', { name: 'Save changes' }).click(); + + // The row no longer renders a flag or a color swatch — the clear actually persisted (the edit + // sent `null` for color/country; re-reading the directory shows them gone). + await expect(row.locator('.flag')).toHaveCount(0, { timeout: 15_000 }); + await expect(row.locator('.color-swatch')).toHaveCount(0); + // The callsign + name are unchanged (only color/country were cleared). + await expect(row.getByText(CALLSIGN)).toBeVisible(); + await expect(row.getByText('Ada Lovelace')).toBeVisible(); + + // ── Remove (with the confirm step) ───────────────────────────────────────────────────── + await row.getByRole('button', { name: 'Remove' }).click(); + const confirm = page.getByRole('dialog').filter({ hasText: 'Remove pilot' }); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button', { name: 'Remove' }).click(); + + // The pilot is gone from the directory. + await expect(list.getByRole('listitem').filter({ hasText: CALLSIGN })).toHaveCount(0, { + timeout: 15_000 + }); +}); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index d454e13..54d34d1 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -179,10 +179,11 @@ test('home hub navigates to each page and back, with working breadcrumbs', async await crumbs.getByRole('button', { name: 'Home' }).click(); await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); - // Home → Pilots: the placeholder page with the "registration UI coming" note. + // Home → Pilots: the directory page hosting the shared PilotManager (#74) — its "+ Add pilot" + // action and the directory list are present. await page.getByRole('button', { name: /Pilots/ }).click(); await expect(page.getByRole('heading', { name: 'Pilots', level: 1 })).toBeVisible(); - await expect(page.getByText(/registration UI/i)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: '+ Add pilot' })).toBeVisible({ timeout: 15_000 }); await page .getByRole('navigation', { name: 'Breadcrumb' }) .getByRole('button', { name: 'Home' }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1f7da6..28bb39e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,8 @@ "dependencies": { "@gridfpv/components": "*", "@gridfpv/protocol-client": "*", - "@gridfpv/types": "*" + "@gridfpv/types": "*", + "country-flag-icons": "^1.6.17" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", @@ -2331,6 +2332,12 @@ "dev": true, "license": "MIT" }, + "node_modules/country-flag-icons": { + "version": "1.6.17", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.17.tgz", + "integrity": "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 2aee126..4685ce9 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -86,4 +86,5 @@ export type * from '@bindings/TimerKind'; export type * from '@bindings/TimerStatus'; export type * from '@bindings/UpdatePilotRequest'; export type * from '@bindings/UpdateTimerRequest'; +export type * from '@bindings/VtxType'; export type * from '@bindings/WinCondition'; From ffd23947273543e67eee549e5cc975afa0ce6fb4 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 03:35:03 +0000 Subject: [PATCH 087/362] In-event Roster: select directory pilots + inline CRUD (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the workspace's "Registration" screen with a new EventRoster that embeds the shared PilotManager and layers per-event roster selection on top, mirroring exactly how EventTimers wraps TimerManager. - EventRoster.svelte: a `rowLead` per-row checkbox (checked = in this event's roster, read from `currentEvent.roster`), a `listFooter` with the rostered count + Save, and a roster-count header ("N of M pilots rostered for this event") that always shows (even on an empty directory, which nudges to add pilots). Selection edits a local working set; Save batches it via `setEventRoster`, then the session re-homes `currentEvent` from the response. The embedded manager's add/edit/remove operate on the app-level directory, so a brand-new pilot can be registered and immediately checked into the event without leaving — the whole point of the slice. - session.svelte.ts: expose `setEventRoster` / `addToRoster` / `removeFromRoster` (full-trust-then-lazy-token write, re-homing `currentEvent`), mirroring `setEventTimers`; wire matching test seams into support.ts. - App.svelte: the "Registration" sidebar item now renders EventRoster (label kept). Removed the orphaned old Registration screen + its test (the `registration.ts` Register command builder, used by heat building, stays). - Tests: EventRoster unit spec (seeds from roster, toggle saves via the roster API in directory order, an inline-created pilot becomes selectable, the count is right, empty-directory nudge); a roster e2e spec (enter Practice → Registration → add a pilot inline → check it in → Save → reload asserts it persisted on `currentEvent.roster` → uncheck/Save → remove). Existing specs (race flow, hub nav, pilot lifecycle) stay green. Heat building (which will draw from the roster) is untouched / out of scope. Closes #74. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 4 +- .../apps/rd-console/src/lib/session.svelte.ts | 62 ++++++ .../rd-console/src/screens/EventRoster.svelte | 195 ++++++++++++++++++ .../src/screens/Registration.svelte | 188 ----------------- .../apps/rd-console/tests/EventRoster.test.ts | 108 ++++++++++ .../tests/RegistrationScreen.test.ts | 36 ---- frontend/apps/rd-console/tests/support.ts | 15 +- frontend/e2e/roster.spec.ts | 107 ++++++++++ 8 files changed, 487 insertions(+), 228 deletions(-) create mode 100644 frontend/apps/rd-console/src/screens/EventRoster.svelte delete mode 100644 frontend/apps/rd-console/src/screens/Registration.svelte create mode 100644 frontend/apps/rd-console/tests/EventRoster.test.ts delete mode 100644 frontend/apps/rd-console/tests/RegistrationScreen.test.ts create mode 100644 frontend/e2e/roster.spec.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index dffc44c..8f70761 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -34,7 +34,7 @@ import TokenDialog from './screens/TokenDialog.svelte'; import SetupWizard from './screens/SetupWizard.svelte'; import EventTimers from './screens/EventTimers.svelte'; - import Registration from './screens/Registration.svelte'; + import EventRoster from './screens/EventRoster.svelte'; import LiveRaceControl from './screens/LiveRaceControl.svelte'; import Marshaling from './screens/Marshaling.svelte'; import Results from './screens/Results.svelte'; @@ -277,7 +277,7 @@ {:else if active === 'timers'} {:else if active === 'registration'} - + {:else if active === 'live'} {:else if active === 'marshaling'} diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index d9418f5..f8aebfb 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -52,6 +52,9 @@ import { createPilot, updatePilot, deletePilot, + setEventRoster, + addToRoster, + removeFromRoster, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -219,6 +222,9 @@ export class Session { #createPilotImpl: typeof createPilot; #updatePilotImpl: typeof updatePilot; #deletePilotImpl: typeof deletePilot; + #setEventRosterImpl: typeof setEventRoster; + #addToRosterImpl: typeof addToRoster; + #removeFromRosterImpl: typeof removeFromRoster; constructor(opts?: { connectImpl?: typeof connect; @@ -237,6 +243,9 @@ export class Session { createPilotImpl?: typeof createPilot; updatePilotImpl?: typeof updatePilot; deletePilotImpl?: typeof deletePilot; + setEventRosterImpl?: typeof setEventRoster; + addToRosterImpl?: typeof addToRoster; + removeFromRosterImpl?: typeof removeFromRoster; baseUrl?: string; autoRestore?: boolean; }) { @@ -256,6 +265,9 @@ export class Session { this.#createPilotImpl = opts?.createPilotImpl ?? createPilot; this.#updatePilotImpl = opts?.updatePilotImpl ?? updatePilot; this.#deletePilotImpl = opts?.deletePilotImpl ?? deletePilot; + this.#setEventRosterImpl = opts?.setEventRosterImpl ?? setEventRoster; + this.#addToRosterImpl = opts?.addToRosterImpl ?? addToRoster; + this.#removeFromRosterImpl = opts?.removeFromRosterImpl ?? removeFromRoster; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -456,6 +468,56 @@ export class Session { return updated; } + /** + * Set the **current event's** roster (`PUT /events/{id}/roster`) — issue #74. The per-event + * reference into the app-level pilot directory: the event rosters which directory pilots race it. + * Pass the full set of pilot ids (replaces the roster wholesale). No-op (resolves `undefined`) + * when no event is selected. On success the updated {@link EventMeta} replaces + * {@link currentEvent} so the workspace's view of the roster stays in sync; returns it, + * `undefined` on a cancelled prompt, or throws. + */ + async setEventRoster(pilotIds: PilotId[]): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#setEventRosterImpl(this.baseUrl, event.id, pilotIds, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + + /** + * Add one pilot to the **current event's** roster (`POST /events/{id}/roster/{pilotId}`) — issue + * #74. Idempotent. No-op (resolves `undefined`) when no event is selected. On success the updated + * {@link EventMeta} replaces {@link currentEvent}; returns it, `undefined` on a cancelled prompt, + * or throws. + */ + async addToRoster(pilotId: PilotId): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#addToRosterImpl(this.baseUrl, event.id, pilotId, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + + /** + * Remove one pilot from the **current event's** roster (`DELETE /events/{id}/roster/{pilotId}`) — + * issue #74. Removing a pilot not on the roster is a no-op. Resolves `undefined` when no event is + * selected. On success the updated {@link EventMeta} replaces {@link currentEvent}; returns it, + * `undefined` on a cancelled prompt, or throws. + */ + async removeFromRoster(pilotId: PilotId): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#removeFromRosterImpl(this.baseUrl, event.id, pilotId, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + /** * Read the id of the Director's **active event** (`GET /active-event`, open, no token) — issue * #91. The picker uses this to mark which event is currently live, independent of whether this diff --git a/frontend/apps/rd-console/src/screens/EventRoster.svelte b/frontend/apps/rd-console/src/screens/EventRoster.svelte new file mode 100644 index 0000000..8947c0a --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventRoster.svelte @@ -0,0 +1,195 @@ + + +
      + + {#snippet actions()} + + {/snippet} + + +

      + {orderedSelection.length} of {pilots.length} + {pilots.length === 1 ? 'pilot' : 'pilots'} rostered for this event +

      + + selected.has(p.id)} + > + {#snippet rowLead(pilot)} + toggle(pilot.id)} + aria-label={`Roster ${pilot.callsign}`} + /> + {/snippet} + + {#snippet listFooter()} +
      + + {orderedSelection.length} rostered + +
      + {#if changed} + + {/if} + +
      +
      + {/snippet} +
      +
      + + + diff --git a/frontend/apps/rd-console/src/screens/Registration.svelte b/frontend/apps/rd-console/src/screens/Registration.svelte deleted file mode 100644 index 33c82fb..0000000 --- a/frontend/apps/rd-console/src/screens/Registration.svelte +++ /dev/null @@ -1,188 +0,0 @@ - - -
      -
      -

      Registration

      -

      - Bind each competitor the timer has seen on {adapter} to a pilot. -

      -
      - - {#if session.lastCommandError} - session.clearCommandError()} /> - {/if} - - {#if seen.length === 0} - -

      No competitors seen yet. They appear here as the timer reports them.

      -
      - {:else} - - - - - - - - - - - {#each seen as ref (ref)} - - - - - - {/each} - -
      Competitor (source ref)PilotAction
      {ref} - pilotInputs[ref] ?? bound[ref] ?? '', - (v) => (pilotInputs = { ...pilotInputs, [ref]: v }) - } - /> - -
      - - {#if bound[ref]} - {bound[ref]} - {/if} -
      -
      -
      - {/if} -
      - - diff --git a/frontend/apps/rd-console/tests/EventRoster.test.ts b/frontend/apps/rd-console/tests/EventRoster.test.ts new file mode 100644 index 0000000..a1671e8 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventRoster.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { EventMeta, Pilot } from '@gridfpv/types'; +import EventRoster from '../src/screens/EventRoster.svelte'; +import { makeTestSession } from './support.js'; + +const ACE: Pilot = { id: 'p1', callsign: 'Ace', name: 'Alice', attributes: {} }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', attributes: {} }; + +/** An event that already rosters Ace (so its checkbox seeds checked). */ +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + roster: ['p1'] +}; + +describe('EventRoster (in-event roster + inline CRUD)', () => { + it('seeds the checkboxes from the event roster and shows the count', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session } = makeTestSession({ listPilotsImpl, event: EVENT }); + render(EventRoster, { session }); + + const aceBox = (await screen.findByLabelText('Roster Ace')) as HTMLInputElement; + const beeBox = screen.getByLabelText('Roster Bee') as HTMLInputElement; + expect(aceBox.checked).toBe(true); + expect(beeBox.checked).toBe(false); + + // The header count reflects 1 of 2 rostered. + expect(screen.getByText(/of 2 pilots rostered for this event/i)).toBeInTheDocument(); + expect(within(screen.getByText(/rostered for this event/i)).getByText('1')).toBeInTheDocument(); + + // No change yet → Save disabled. + expect( + (screen.getByRole('button', { name: 'Save roster' }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + it('toggling a row saves the working roster via setEventRoster in directory order', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const setEventRosterImpl = vi.fn(async () => ({ ...EVENT, roster: ['p1', 'p2'] })); + const { session } = makeTestSession({ listPilotsImpl, setEventRosterImpl, event: EVENT }); + render(EventRoster, { session }); + + const beeBox = (await screen.findByLabelText('Roster Bee')) as HTMLInputElement; + await fireEvent.click(beeBox); + + const save = screen.getByRole('button', { name: 'Save roster' }) as HTMLButtonElement; + expect(save.disabled).toBe(false); + await fireEvent.click(save); + + await waitFor(() => expect(setEventRosterImpl).toHaveBeenCalledTimes(1)); + expect(setEventRosterImpl).toHaveBeenCalledWith('http://d.local', 'e1', ['p1', 'p2'], 'tok'); + // currentEvent re-homes to the server's response, so the saved roster sticks. + await waitFor(() => expect(session.currentEvent?.roster).toEqual(['p1', 'p2'])); + }); + + it('unchecking a rostered pilot saves the smaller roster', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const setEventRosterImpl = vi.fn(async () => ({ ...EVENT, roster: [] })); + const { session } = makeTestSession({ listPilotsImpl, setEventRosterImpl, event: EVENT }); + render(EventRoster, { session }); + + const aceBox = (await screen.findByLabelText('Roster Ace')) as HTMLInputElement; + await fireEvent.click(aceBox); // remove the only rostered pilot + await fireEvent.click(screen.getByRole('button', { name: 'Save roster' })); + + await waitFor(() => expect(setEventRosterImpl).toHaveBeenCalledTimes(1)); + expect(setEventRosterImpl).toHaveBeenCalledWith('http://d.local', 'e1', [], 'tok'); + }); + + it('an inline-created pilot becomes selectable without leaving the event', async () => { + const created: Pilot = { id: 'p9', callsign: 'Newbie', attributes: {} }; + let calls = 0; + const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [ACE] : [ACE, created])); + const createPilotImpl = vi.fn(async () => created); + const { session } = makeTestSession({ + listPilotsImpl, + createPilotImpl, + event: { ...EVENT, roster: [] } + }); + render(EventRoster, { session }); + + await screen.findByLabelText('Roster Ace'); + await fireEvent.click(screen.getByRole('button', { name: '+ Add pilot' })); + + const callsign = (await screen.findByLabelText('Callsign')) as HTMLInputElement; + await fireEvent.input(callsign, { target: { value: 'Newbie' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); + + await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); + // The newly-created pilot appears as a fresh, unchecked, selectable row. + const newBox = (await screen.findByLabelText('Roster Newbie')) as HTMLInputElement; + expect(newBox.checked).toBe(false); + await fireEvent.click(newBox); + expect(newBox.checked).toBe(true); + }); + + it('nudges to add pilots when the directory is empty', async () => { + const listPilotsImpl = vi.fn(async () => []); + const { session } = makeTestSession({ listPilotsImpl, event: { ...EVENT, roster: [] } }); + render(EventRoster, { session }); + await screen.findByText(/No pilots in the directory yet/i); + }); +}); diff --git a/frontend/apps/rd-console/tests/RegistrationScreen.test.ts b/frontend/apps/rd-console/tests/RegistrationScreen.test.ts deleted file mode 100644 index 98f3dab..0000000 --- a/frontend/apps/rd-console/tests/RegistrationScreen.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { fireEvent, within } from '@testing-library/dom'; -import Registration from '../src/screens/Registration.svelte'; -import { makeTestSession } from './support.js'; -import { liveRunning } from './fixtures.js'; - -describe('Registration', () => { - it('lists the seen competitor refs from the live lineup', () => { - const { session } = makeTestSession({ live: liveRunning }); - render(Registration, { session }); - expect(screen.getByText('ALICE')).toBeInTheDocument(); - expect(screen.getByText('BOB')).toBeInTheDocument(); - }); - - it('emits a Register command binding the ref to the typed pilot id', async () => { - const { session, sendSpy } = makeTestSession({ live: liveRunning }); - render(Registration, { session, adapter: 'rh-1' }); - - const input = screen.getByLabelText('Pilot for ALICE') as HTMLInputElement; - await fireEvent.input(input, { target: { value: 'pilot-alice' } }); - // Scope to ALICE's row (each row has its own Register button). - const row = input.closest('tr')!; - await fireEvent.click(within(row).getByRole('button', { name: 'Register' })); - - expect(sendSpy).toHaveBeenCalledWith({ - Register: { adapter: 'rh-1', competitor: 'ALICE', pilot: 'pilot-alice' } - }); - }); - - it('shows an empty state when no competitors are seen yet', () => { - const { session } = makeTestSession({ live: { phase: 'Scheduled' } }); - render(Registration, { session }); - expect(screen.getByText(/No competitors seen yet/i)).toBeInTheDocument(); - }); -}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 17251ca..340ea02 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -17,7 +17,10 @@ import type { listPilots, createPilot, updatePilot, - deletePilot + deletePilot, + setEventRoster, + addToRoster, + removeFromRoster } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -46,6 +49,10 @@ export interface TimerImpls { createPilotImpl?: typeof createPilot; updatePilotImpl?: typeof updatePilot; deletePilotImpl?: typeof deletePilot; + /** The per-event roster write seams (issue #74) — back the EventRoster tests. */ + setEventRosterImpl?: typeof setEventRoster; + addToRosterImpl?: typeof addToRoster; + removeFromRosterImpl?: typeof removeFromRoster; } export interface TestSession { @@ -101,7 +108,11 @@ export function makeTestSession( // Pilot-directory write seams (issue #74): inert unless a test overrides them. createPilotImpl: opts?.createPilotImpl, updatePilotImpl: opts?.updatePilotImpl, - deletePilotImpl: opts?.deletePilotImpl + deletePilotImpl: opts?.deletePilotImpl, + // Per-event roster write seams (issue #74): inert unless a test overrides them. + setEventRosterImpl: opts?.setEventRosterImpl, + addToRosterImpl: opts?.addToRosterImpl, + removeFromRosterImpl: opts?.removeFromRosterImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/e2e/roster.spec.ts b/frontend/e2e/roster.spec.ts new file mode 100644 index 0000000..f98a89c --- /dev/null +++ b/frontend/e2e/roster.spec.ts @@ -0,0 +1,107 @@ +/** + * In-event roster through the console UI (#74) — the deliverable proof for the event-roster slice. + * + * A person enters an event (Practice), opens the workspace's **Registration** screen (now the + * EventRoster), and — without leaving the event — **registers a brand-new pilot inline**, then + * **checks it into this event's roster** and **saves**. The roster is asserted to have persisted on + * the Director (a reload resumes into the event with the pilot still checked). Finally the pilot is + * **unchecked + saved** (roster shrinks) and **removed** from the directory — cleaning up after + * itself, since the worker's Director is shared. + * + * Every step is a real click/input in headless chromium on the real `POST /pilots` + + * `PUT /events/{id}/roster` paths — nothing mocked. Importing `test`/`expect` from + * `./observability.js` means a failure carries the full-stack dump (browser console, page errors, + * the Director's server log). + */ +import { expect, test } from './observability.js'; + +const CALLSIGN = `E2E-Roster-${Date.now()}`; + +test('RD registers a pilot inline and checks it into the event roster', async ({ page }) => { + await page.goto('/'); + + // ── Get into an event (Practice). The worker's Director may already be in a workspace from a + // prior spec; if we're on the hub, go Events → Practice. ─────────────────────────────────── + const liveNav = page.getByRole('button', { name: /Live control/ }); + const eventsCard = page.getByRole('button', { name: /Events/ }); + await expect(liveNav.or(eventsCard).first()).toBeVisible({ timeout: 15_000 }); + if (!(await liveNav.isVisible().catch(() => false))) { + await eventsCard.click(); + await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ + timeout: 15_000 + }); + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + } + + // ── Open the Registration screen (the EventRoster) from the workspace sidebar ─────────────── + await page + .getByRole('navigation', { name: 'Screens' }) + .getByRole('button', { name: 'Registration' }) + .click(); + await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeVisible({ + timeout: 15_000 + }); + // The roster count header is present. + await expect(page.getByText(/rostered for this event/i)).toBeVisible(); + + // ── Register a brand-new pilot inline — without leaving the event ──────────────────────────── + await page.getByRole('button', { name: '+ Add pilot' }).click(); + const addForm = page.getByRole('form', { name: 'Add pilot' }); + await expect(addForm).toBeVisible(); + await addForm.getByLabel('Callsign').fill(CALLSIGN); + // Submit (open Director — no token prompt). `exact` to pick the dialog's submit, not the header's. + await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // The new pilot appears in the list as a fresh, unchecked, selectable row. + const list = page.getByRole('list', { name: 'Pilot directory' }); + const row = list.getByRole('listitem').filter({ hasText: CALLSIGN }); + await expect(row).toBeVisible({ timeout: 15_000 }); + const box = row.getByRole('checkbox', { name: `Roster ${CALLSIGN}` }); + await expect(box).not.toBeChecked(); + + // ── Check it into the event roster, then Save ─────────────────────────────────────────────── + await box.check(); + await expect(box).toBeChecked(); + await page.getByRole('button', { name: 'Save roster' }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + // After a successful save there is nothing pending, so Save goes disabled again. + await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); + + // ── It persisted on the Director: a reload resumes into the event with the pilot still checked. + await page.reload(); + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + await page + .getByRole('navigation', { name: 'Screens' }) + .getByRole('button', { name: 'Registration' }) + .click(); + const rowAfter = page + .getByRole('list', { name: 'Pilot directory' }) + .getByRole('listitem') + .filter({ hasText: CALLSIGN }); + await expect(rowAfter.getByRole('checkbox', { name: `Roster ${CALLSIGN}` })).toBeChecked({ + timeout: 15_000 + }); + + // ── Uncheck + Save: the roster shrinks back (the remove side of the toggle) ────────────────── + const boxAfter = rowAfter.getByRole('checkbox', { name: `Roster ${CALLSIGN}` }); + await boxAfter.uncheck(); + await expect(boxAfter).not.toBeChecked(); + await page.getByRole('button', { name: 'Save roster' }).click(); + await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); + + // ── Clean up: remove the pilot from the directory (the worker's Director is shared) ────────── + await rowAfter.getByRole('button', { name: 'Remove' }).click(); + const confirm = page.getByRole('dialog').filter({ hasText: 'Remove pilot' }); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button', { name: 'Remove' }).click(); + await expect( + page.getByRole('list', { name: 'Pilot directory' }).getByRole('listitem').filter({ + hasText: CALLSIGN + }) + ).toHaveCount(0, { timeout: 15_000 }); +}); From aee5cbbe557e6aadc1676d008096c6dd3a36386c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 20:23:44 +0000 Subject: [PATCH 088/362] Console: hash-based routing so refresh preserves the view (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RD console's view (app page or in-event workspace tab) lived only in in-memory state, so a browser refresh reset it — losing where you were. Reflect the view in `location.hash` and make the hash the source of truth for navigation, so refresh stays put and back/forward + bookmarks work. Why hash, not path: `/pilots`, `/events`, `/timers` are already Director API routes, so a path router would collide with the server. The `#` fragment is client-side only and can never collide. Scheme: `#/` hub; `#/pilots` `#/events` `#/timers` pages; `#/event/` for the workspace (tab ∈ setup|registration|live|marshaling|results|timers). The active event is server state (#90), so the hash restores the *tab*; the workspace always shows the Director's active event. - `lib/route.ts`: tiny parse/format helper + reconciliation (no router dep). - App.svelte: `route` derived from the hash; navigation sets the hash; a `hashchange`/`popstate` listener re-derives. Composes with the active-event resume: empty hash + active event resumes the workspace; an explicit page hash is honoured even with an active event; a workspace hash with no active event reconciles to the Events page (never a broken workspace). - Tests: `tests/route.test.ts` (round-trip, unknown→hub, workspace-without- event→events) and `e2e/routing.spec.ts` (reload stays on Pilots; reload stays on the Registration tab inside an event; browser back/forward). No dependency added. Existing e2e specs stay green. Part of #118. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 137 ++++++++++++++----- frontend/apps/rd-console/src/lib/route.ts | 129 +++++++++++++++++ frontend/apps/rd-console/tests/route.test.ts | 126 +++++++++++++++++ frontend/e2e/routing.spec.ts | 100 ++++++++++++++ 4 files changed, 458 insertions(+), 34 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/route.ts create mode 100644 frontend/apps/rd-console/tests/route.test.ts create mode 100644 frontend/e2e/routing.spec.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 8f70761..824a1c1 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -38,28 +38,82 @@ import LiveRaceControl from './screens/LiveRaceControl.svelte'; import Marshaling from './screens/Marshaling.svelte'; import Results from './screens/Results.svelte'; + import { + parseHash, + formatHash, + reconcileRoute, + resolveInitialRoute, + type Route, + type AppPage, + type WorkspaceTab + } from './lib/route.js'; const session = new Session(); - // ── App-level routing (#118) ─────────────────────────────────────────────── - // The two-level IA: with no event selected the shell is on one of the app-level routes — the hub - // or one of its three pages. Entering an event switches to the workspace; leaving the workspace - // returns to the Events page (where the switch happened). `goHome` is the brand/breadcrumb root. - type AppRoute = 'home' | 'events' | 'timers' | 'pilots'; - let route = $state('home'); - const goHome = () => (route = 'home'); + // ── Hash-based URL routing (#118) ────────────────────────────────────────── + // The view is reflected in `location.hash` so a refresh stays on the same page/tab and + // back/forward + bookmarks work. The hash is the source of truth: navigating sets it, and the + // reactive `route` is parsed from it on load and on every `hashchange`/`popstate`. We use a hash + // (not a path) because `/pilots`, `/events`, `/timers` are already Director API routes — the `#` + // stays client-side and can't collide. See `lib/route.ts` for the scheme + reconciliation rules. + // + // `route` is the *intended* view. It's reconciled against the live active event (server state) + // for rendering: a workspace route with no active event reconciles to the Events page, and an + // empty/hub hash with an active event resumes into the workspace (#90), all in `lib/route.ts`. + let route = $state(parseHash(location.hash)); + + // Navigate to a route: this is the single mutation point. It updates `location.hash`, which (for + // a real change) fires `hashchange` and reloads `route` from the hash — keeping hash and view in + // lockstep. We also set `route` directly so a no-op hash write (same hash) still re-renders. + function navigate(next: Route) { + const hash = formatHash(next); + route = next; + if (location.hash !== hash) location.hash = hash; + } + + // Page navigations (hub cards, breadcrumbs, page "Home" crumbs). + const goPage = (page: AppPage) => navigate({ kind: 'page', page }); + const goHome = () => goPage('home'); + // The workspace's app-route concept is "Events" (where event entry/switch happens). + const route$page = $derived(route.kind === 'page' ? route.page : 'home'); + + // On `hashchange`/`popstate` (back/forward, manual edit, or our own `navigate`) re-derive the + // route from the hash. Reconcile against the live active event so a workspace hash with no event + // doesn't render a broken workspace. + function onHashChange() { + route = reconcileRoute(parseHash(location.hash), !!session.currentEvent); + } // The lazy token prompt: register a provider that opens the TokenDialog and resolves // with the entered token (or undefined if cancelled). This is the only auth surface left. let tokenDialog = $state(); session.setTokenProvider(() => tokenDialog?.request() ?? Promise.resolve(undefined)); - // Resume into the Director's active event on load (#90): the active event is server-side - // state, so a reload/reconnect/app-restart reads `GET /active-event` and re-enters the same - // event instead of dropping to the picker. While this resolves we show a brief loading state; - // it then settles into the workspace (active set) or the picker (none / unreachable). + // Resume into the Director's active event on load (#90), composed with the hash route (#118): + // the active event is server-side state, so a reload reads `GET /active-event` and re-enters the + // same event. Once it settles we resolve the *initial* route from the hash + whether an event is + // active — an `#/event/` hash restores that tab, a top-level page hash shows that page, and + // an empty hash resumes into the workspace (active set) or lands on the hub. While it resolves we + // show a brief loading state. After this one-shot resume, the hash stays the source of truth. + let resumed = false; $effect(() => { - void session.resolveActiveEvent(); + if (resumed) return; + resumed = true; + void (async () => { + await session.resolveActiveEvent(); + navigate(resolveInitialRoute(location.hash, !!session.currentEvent)); + })(); + }); + + // Entering an event happens *inside* the EventPicker via session state (`chooseEvent` / + // `createEventAndEnter` set `session.currentEvent`), not through a `navigate` call here. So when + // the active event appears while we're on the Events page, flip the route into the workspace so + // the hash becomes `#/event/live`. This is the routing counterpart of the old `route = 'events'` + // → workspace transition that used to be implicit in `{#if session.currentEvent}`. + $effect(() => { + if (session.currentEvent && route.kind === 'page' && route.page === 'events') { + navigate({ kind: 'workspace', tab: 'live' }); + } }); type ScreenId = 'setup' | 'timers' | 'registration' | 'live' | 'marshaling' | 'results'; @@ -81,8 +135,12 @@ icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z' } ]; - let active = $state('live'); + // The active workspace tab is derived from the route (the hash is the source of truth, #118): + // a `workspace` route carries the tab; outside the workspace it's the default (`live`). Switching + // a sidebar tab is `setTab`, which sets the `#/event/` hash so a refresh restores it. + const active = $derived(route.kind === 'workspace' ? route.tab : 'live'); const activeScreen = $derived(SCREENS.find((s) => s.id === active)); + const setTab = (tab: WorkspaceTab) => navigate({ kind: 'workspace', tab }); // The setup wizard's config lives at the shell so it survives screen switches. let config = $state(emptyConfig()); @@ -91,25 +149,24 @@ // The console is already inside an event (#72) — the live read client was scoped to it on // entry — so committing the wizard just advances to registration; there is no separate // event to re-scope to (the redundant event field was removed, #72 Slice 1b A1). - active = 'registration'; + setTab('registration'); } function leaveToPicker() { session.leaveEvent(); // Reset the workspace's local view so re-entering starts clean, and land back on the Events - // page (the app route the "Switch event" action conceptually returns to). - active = 'live'; + // page (the app route the "Switch event" action conceptually returns to). Setting the hash to + // `#/events` keeps the URL honest after leaving the workspace. config = emptyConfig(); - route = 'events'; + goPage('events'); } // Leave the workspace straight to the **home hub** (#118) — the brand/logo and the breadcrumb's // "Home" crumb both use this. Same teardown as a switch, but lands on the hub, not the picker. function goToHubFromWorkspace() { session.leaveEvent(); - active = 'live'; config = emptyConfig(); - route = 'home'; + goHome(); } // A keyboard shortcut per screen (Alt+digit), keeping the console keyboard-driven. @@ -118,7 +175,7 @@ if (e.altKey && !e.ctrlKey && !e.metaKey) { const match = SCREENS.find((s) => s.key === e.key); if (match) { - active = match.id; + setTab(match.id); e.preventDefault(); } } @@ -144,7 +201,7 @@ } - + {#if session.resolvingActiveEvent && !session.currentEvent} +{:else if route.kind === 'page'} +
      - {#if route === 'home'} + {#if route$page === 'home'} (route = 'pilots')} - onevents={() => (route = 'events')} - ontimers={() => (route = 'timers')} + onpilots={() => goPage('pilots')} + onevents={() => goPage('events')} + ontimers={() => goPage('timers')} /> - {:else if route === 'events'} + {:else if route$page === 'events'} - {:else if route === 'timers'} + {:else if route$page === 'timers'} - {:else if route === 'pilots'} + {:else if route$page === 'pilots'} {/if}
      -{:else} +{:else if session.currentEvent}
      + + + + + +
      + {#each VTX_TYPES as vtx (vtx)} + {@const selected = form.vtx_types.includes(vtx)} + + {/each} +
      +
      @@ -561,6 +575,42 @@ gap: var(--gf-space-3); } + .vtx-chips { + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .vtx-chip { + appearance: none; + cursor: pointer; + font: inherit; + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-semibold); + line-height: 1; + padding: var(--gf-space-3) var(--gf-space-4); + border-radius: var(--gf-radius-pill); + border: 2px solid var(--gf-border-strong); + background: var(--gf-surface-sunken); + color: var(--gf-text-secondary); + transition: + border-color var(--gf-motion-fast) var(--gf-ease-out), + background var(--gf-motion-fast) var(--gf-ease-out), + color var(--gf-motion-fast) var(--gf-ease-out); + } + .vtx-chip:hover { + border-color: var(--gf-accent); + color: var(--gf-text); + } + .vtx-chip:focus-visible { + outline: 2px solid var(--gf-accent); + outline-offset: 2px; + } + .vtx-chip.selected { + border-color: var(--gf-accent); + background: var(--gf-accent); + color: var(--gf-text-on-accent); + } + .color-control { display: flex; align-items: center; diff --git a/frontend/apps/rd-console/tests/EventRoster.test.ts b/frontend/apps/rd-console/tests/EventRoster.test.ts index a1671e8..8ac544e 100644 --- a/frontend/apps/rd-console/tests/EventRoster.test.ts +++ b/frontend/apps/rd-console/tests/EventRoster.test.ts @@ -5,8 +5,8 @@ import type { EventMeta, Pilot } from '@gridfpv/types'; import EventRoster from '../src/screens/EventRoster.svelte'; import { makeTestSession } from './support.js'; -const ACE: Pilot = { id: 'p1', callsign: 'Ace', name: 'Alice', attributes: {} }; -const BEE: Pilot = { id: 'p2', callsign: 'Bee', attributes: {} }; +const ACE: Pilot = { id: 'p1', callsign: 'Ace', name: 'Alice', vtx_types: [], attributes: {} }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; /** An event that already rosters Ace (so its checkbox seeds checked). */ const EVENT: EventMeta = { @@ -73,7 +73,7 @@ describe('EventRoster (in-event roster + inline CRUD)', () => { }); it('an inline-created pilot becomes selectable without leaving the event', async () => { - const created: Pilot = { id: 'p9', callsign: 'Newbie', attributes: {} }; + const created: Pilot = { id: 'p9', callsign: 'Newbie', vtx_types: [], attributes: {} }; let calls = 0; const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [ACE] : [ACE, created])); const createPilotImpl = vi.fn(async () => created); diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts index acfb4ab..326f02a 100644 --- a/frontend/apps/rd-console/tests/HomeHub.test.ts +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -6,8 +6,8 @@ import HomeHub from '../src/screens/HomeHub.svelte'; import { makeTestSession } from './support.js'; const PILOTS: Pilot[] = [ - { id: 'p1', callsign: 'Ace', attributes: {} }, - { id: 'p2', callsign: 'Bee', attributes: {} } + { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }, + { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} } ]; const EVENTS: EventMeta[] = [ { diff --git a/frontend/apps/rd-console/tests/PilotManager.test.ts b/frontend/apps/rd-console/tests/PilotManager.test.ts index c97e073..39cf297 100644 --- a/frontend/apps/rd-console/tests/PilotManager.test.ts +++ b/frontend/apps/rd-console/tests/PilotManager.test.ts @@ -11,9 +11,10 @@ const ACE: Pilot = { name: 'Alice', color: '#ff0000', country: 'US', + vtx_types: ['Analog', 'HDZero'], attributes: { bib: '7' } }; -const BEE: Pilot = { id: 'p2', callsign: 'Bee', attributes: {} }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; /** Open the Add form via the manager's exported `openAdd()` (the page button calls it). */ async function openAdd(manager: { openAdd: () => void }) { @@ -33,6 +34,64 @@ describe('PilotManager (#74)', () => { expect(within(list).getAllByRole('listitem')).toHaveLength(2); }); + it('renders every selected VTX type as a badge (and none when the set is empty)', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); + render(PilotManager, { session }); + + await screen.findByText('Ace'); + const list = screen.getByRole('list', { name: 'Pilot directory' }); + const [aceRow, beeRow] = within(list).getAllByRole('listitem'); + // Ace has two VTX types → two badges. + expect(within(aceRow).getByText('Analog')).toBeInTheDocument(); + expect(within(aceRow).getByText('HDZero')).toBeInTheDocument(); + // Bee has none → no VTX badges at all. + expect(within(beeRow).queryByText('Analog')).not.toBeInTheDocument(); + expect(within(beeRow).queryByText('HDZero')).not.toBeInTheDocument(); + expect(within(beeRow).queryByText('DJI')).not.toBeInTheDocument(); + }); + + it('toggles VTX chips and sends the selected set in the create body', async () => { + let calls = 0; + const created: Pilot = { + id: 'p9', + callsign: 'Neo', + vtx_types: ['Analog', 'DJI'], + attributes: {} + }; + const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); + const createPilotImpl = vi.fn(async () => created); + const { session } = makeTestSession({ noEnter: true, listPilotsImpl, createPilotImpl }); + const { component } = render(PilotManager, { session }); + await screen.findByText(/No pilots/i); + await openAdd(component as unknown as { openAdd: () => void }); + + await fireEvent.input(screen.getByLabelText('Callsign'), { target: { value: 'Neo' } }); + + const vtxGroup = screen.getByRole('group', { name: 'VTX types' }); + // Tick DJI then Analog (out of canonical order) — and tick HDZero then untick it. + await fireEvent.click(within(vtxGroup).getByRole('button', { name: 'DJI' })); + await fireEvent.click(within(vtxGroup).getByRole('button', { name: 'Analog' })); + await fireEvent.click(within(vtxGroup).getByRole('button', { name: 'HDZero' })); + await fireEvent.click(within(vtxGroup).getByRole('button', { name: 'HDZero' })); // toggled back off + expect(within(vtxGroup).getByRole('button', { name: 'DJI' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(within(vtxGroup).getByRole('button', { name: 'HDZero' })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + + await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); + await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); + expect(createPilotImpl).toHaveBeenCalledWith( + 'http://d.local', + expect.objectContaining({ callsign: 'Neo', vtx_types: ['Analog', 'DJI'] }), + 'tok' + ); + }); + it('shows the empty state when the directory has no pilots', async () => { const listPilotsImpl = vi.fn(async () => []); const { session } = makeTestSession({ noEnter: true, listPilotsImpl }); @@ -62,6 +121,7 @@ describe('PilotManager (#74)', () => { name: 'Thomas', country: 'US', color: '#00ff00', + vtx_types: [], attributes: { sponsor: 'Acme' } }; const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); diff --git a/frontend/apps/rd-console/tests/PilotsPage.test.ts b/frontend/apps/rd-console/tests/PilotsPage.test.ts index dac23e1..36aae6b 100644 --- a/frontend/apps/rd-console/tests/PilotsPage.test.ts +++ b/frontend/apps/rd-console/tests/PilotsPage.test.ts @@ -8,8 +8,8 @@ import { makeTestSession } from './support.js'; const noop = () => {}; const PILOTS: Pilot[] = [ - { id: 'p1', callsign: 'Ace', name: 'Alice', country: 'US', attributes: {} }, - { id: 'p2', callsign: 'Bee', attributes: {} } + { id: 'p1', callsign: 'Ace', name: 'Alice', country: 'US', vtx_types: [], attributes: {} }, + { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} } ]; describe('PilotsPage (#74) — hosts the shared PilotManager', () => { diff --git a/frontend/apps/rd-console/tests/pilots.test.ts b/frontend/apps/rd-console/tests/pilots.test.ts index c05bf1b..d17f26b 100644 --- a/frontend/apps/rd-console/tests/pilots.test.ts +++ b/frontend/apps/rd-console/tests/pilots.test.ts @@ -6,6 +6,8 @@ import { cleanAttributes, emptyForm, formFromPilot, + normalizeVtxTypes, + toggleVtxType, type PilotFormValues } from '../src/lib/pilots.js'; @@ -18,7 +20,7 @@ const FULL: Pilot = { team: 'Red', color: '#FF0000', country: 'US', - vtx_type: 'DJI', + vtx_types: ['Analog', 'DJI'], multigp_id: 'mg-1', velocidrone_id: 'vd-1', attributes: { bib: '7' } @@ -31,11 +33,17 @@ describe('buildCreateRequest', () => { v.name = 'Alice'; v.country = 'us'; // lower-case input → uppercased const req = buildCreateRequest(v); - expect(req).toEqual({ callsign: 'Ace', name: 'Alice', country: 'US', attributes: {} }); + // vtx_types + attributes always go (both default-empty server-side). + expect(req).toEqual({ + callsign: 'Ace', + name: 'Alice', + country: 'US', + vtx_types: [], + attributes: {} + }); // Untouched optionals are absent (not empty strings). expect(req).not.toHaveProperty('team'); expect(req).not.toHaveProperty('color'); - expect(req).not.toHaveProperty('vtx_type'); }); it('carries the cleaned attributes bag', () => { @@ -45,6 +53,31 @@ describe('buildCreateRequest', () => { const req = buildCreateRequest(v); expect(req.attributes).toEqual({ license: 'FAA-9' }); }); + + it('sends the selected VTX set, deduped + in canonical order', () => { + const v = emptyForm(); + v.callsign = 'Ace'; + v.vtx_types = ['HDZero', 'Analog', 'HDZero']; // out of order + a duplicate + const req = buildCreateRequest(v); + expect(req.vtx_types).toEqual(['Analog', 'HDZero']); + }); +}); + +describe('VTX set helpers', () => { + it('normalizeVtxTypes dedups and orders canonically', () => { + expect(normalizeVtxTypes(['Other', 'DJI', 'DJI', 'Analog'])).toEqual([ + 'Analog', + 'DJI', + 'Other' + ]); + }); + + it('toggleVtxType adds when absent and removes when present', () => { + expect(toggleVtxType(['Analog'], 'HDZero')).toEqual(['Analog', 'HDZero']); + expect(toggleVtxType(['Analog', 'HDZero'], 'Analog')).toEqual(['HDZero']); + // Re-adding keeps the canonical order regardless of click order. + expect(toggleVtxType(['HDZero'], 'Analog')).toEqual(['Analog', 'HDZero']); + }); }); describe('buildUpdateRequest — clear-via-null', () => { @@ -76,11 +109,21 @@ describe('buildUpdateRequest — clear-via-null', () => { expect(req).toEqual({ name: 'Alicia' }); }); - it('clears the VTX type with null when set to None', () => { - const v = valuesFrom(FULL); - v.vtx = ''; - const req = buildUpdateRequest(FULL, v); - expect(req.vtx_type).toBeNull(); + it('sends the VTX set only when it differs (order-independent); empty array clears it', () => { + // Unchanged set (same members, any order) → omitted. + const same = valuesFrom(FULL); + same.vtx_types = ['DJI', 'Analog']; + expect(buildUpdateRequest(FULL, same)).not.toHaveProperty('vtx_types'); + + // Adding a type → the full replacement set (deduped, canonical order). + const added = valuesFrom(FULL); + added.vtx_types = [...added.vtx_types, 'Other']; + expect(buildUpdateRequest(FULL, added).vtx_types).toEqual(['Analog', 'DJI', 'Other']); + + // Removing all → an empty array (clears the set; there is no null / "None"). + const cleared = valuesFrom(FULL); + cleared.vtx_types = []; + expect(buildUpdateRequest(FULL, cleared).vtx_types).toEqual([]); }); it('uppercases a changed country code and only sends it when it differs', () => { diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index e31bf53..e13c0a2 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProtocolClient, ProtocolState, StateListener } from '@gridfpv/protocol-client'; -import type { CommandAck, EventMeta, Pilot, Timer } from '@gridfpv/types'; +import type { CommandAck, CreatePilotRequest, EventMeta, Pilot, Timer } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; import { liveRunning, okAck, failAck } from './fixtures.js'; @@ -719,7 +719,7 @@ describe('Session', () => { }); describe('pilots (#74)', () => { - const ACE: Pilot = { id: 'p1', callsign: 'Ace', attributes: {} }; + const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }; function pilotSession(overrides?: { listPilotsImpl?: ReturnType; @@ -752,7 +752,7 @@ describe('Session', () => { it('createPilot returns the new pilot (full-trust, no token)', async () => { const createPilotImpl = vi.fn(async () => ACE); const session = pilotSession({ createPilotImpl }); - const req = { callsign: 'Ace', attributes: {} } as const; + const req: CreatePilotRequest = { callsign: 'Ace', vtx_types: [], attributes: {} }; const result = await session.createPilot(req); expect(result).toEqual(ACE); expect(createPilotImpl).toHaveBeenCalledWith('http://d.local', req, undefined); @@ -765,7 +765,7 @@ describe('Session', () => { .mockResolvedValueOnce(ACE); const session = pilotSession({ createPilotImpl }); session.setTokenProvider(async () => 'tok'); - const result = await session.createPilot({ callsign: 'Ace', attributes: {} }); + const result = await session.createPilot({ callsign: 'Ace', vtx_types: [], attributes: {} }); expect(result).toEqual(ACE); expect(createPilotImpl).toHaveBeenCalledTimes(2); expect(createPilotImpl).toHaveBeenLastCalledWith('http://d.local', expect.anything(), 'tok'); @@ -775,7 +775,7 @@ describe('Session', () => { const createPilotImpl = vi.fn().mockRejectedValue(new Error('POST /pilots failed: HTTP 401')); const session = pilotSession({ createPilotImpl }); session.setTokenProvider(async () => undefined); - const result = await session.createPilot({ callsign: 'X', attributes: {} }); + const result = await session.createPilot({ callsign: 'X', vtx_types: [], attributes: {} }); expect(result).toBeUndefined(); expect(createPilotImpl).toHaveBeenCalledTimes(1); }); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 7e60d4e..889e94f 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -442,7 +442,7 @@ describe('seam 10b: primary/alternate timer roles (#112)', () => { * guards: * - `GET /pilots` is an **open read** → `Pilot[]` (empty on a fresh Director — no built-in pilot). * - `POST /pilots` is **RD-gated** (no/bad token → 401), requires a `callsign`, auto-generates an - * id, carries optional metadata incl. the cloud-pull `vtx_type`/`multigp_id` hooks, and the new + * id, carries optional metadata incl. the cloud-pull `vtx_types`/`multigp_id` hooks, and the new * pilot then appears in the listing. * - `PUT /events/{id}/roster` is **RD-gated**, validates each id names a directory pilot (unknown → * 404 `UnknownScope`), and records the roster on the event's `EventMeta.roster` (empty default). @@ -520,7 +520,7 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { team: 'Team Zoom', color: '#1188ff', country: 'gb', // normalized uppercase by the server - vtx_type: 'HDZero', + vtx_types: ['Analog', 'HDZero'], multigp_id: 'mgp-42', attributes: { bib: '7', insurance: 'AMA-123' } }, @@ -534,7 +534,7 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { expect(created.team).toBe('Team Zoom'); expect(created.color).toBe('#1188FF'); // hex normalized uppercase expect(created.country).toBe('GB'); // ISO alpha-2, uppercased - expect(created.vtx_type).toBe('HDZero'); + expect(created.vtx_types).toEqual(['Analog', 'HDZero']); expect(created.multigp_id).toBe('mgp-42'); expect(created.attributes).toEqual({ bib: '7', insurance: 'AMA-123' }); diff --git a/frontend/e2e/pilots.spec.ts b/frontend/e2e/pilots.spec.ts index 74aa529..af5f356 100644 --- a/frontend/e2e/pilots.spec.ts +++ b/frontend/e2e/pilots.spec.ts @@ -50,6 +50,11 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', // Color (a native color input writes #rrggbb). await addForm.getByLabel('Color').fill('#ff8800'); + // VTX is a multi-select of toggle chips: tick two (Analog + HDZero). + const vtxGroup = addForm.getByRole('group', { name: 'VTX types' }); + await vtxGroup.getByRole('button', { name: 'Analog' }).click(); + await vtxGroup.getByRole('button', { name: 'HDZero' }).click(); + // Country: open the searchable selector, search, pick United States. await addForm.getByRole('button', { name: 'Country', exact: true }).click(); await addForm.getByRole('textbox', { name: 'Search countries' }).fill('United States'); @@ -74,8 +79,11 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', await expect(row.locator('.flag svg')).toBeVisible(); // The color swatch reflects the chosen color. await expect(row.locator('.color-swatch')).toBeVisible(); + // Both VTX types render as badges on the row. + await expect(row.getByText('Analog', { exact: true })).toBeVisible(); + await expect(row.getByText('HDZero', { exact: true })).toBeVisible(); - // ── Edit: clear the color and the country ────────────────────────────────────────────── + // ── Edit: clear the color + country, and change the VTX set (drop Analog, add DJI) ────── await row.getByRole('button', { name: 'Edit' }).click(); const editForm = page.getByRole('form', { name: 'Edit pilot' }); await expect(editForm).toBeVisible(); @@ -84,6 +92,19 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', 'United States' ); + // The two previously-ticked chips are pressed; toggle Analog off and DJI on. + const editVtx = editForm.getByRole('group', { name: 'VTX types' }); + await expect(editVtx.getByRole('button', { name: 'Analog' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(editVtx.getByRole('button', { name: 'HDZero' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await editVtx.getByRole('button', { name: 'Analog' }).click(); + await editVtx.getByRole('button', { name: 'DJI' }).click(); + // Clear the color (its inline Clear button) and the country (the selector's clear control). await editForm.getByRole('button', { name: 'Clear', exact: true }).click(); await editForm.getByRole('button', { name: 'Clear country' }).click(); @@ -96,6 +117,10 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', // The callsign + name are unchanged (only color/country were cleared). await expect(row.getByText(CALLSIGN)).toBeVisible(); await expect(row.getByText('Ada Lovelace')).toBeVisible(); + // The VTX set changed: Analog gone, HDZero kept, DJI added. + await expect(row.getByText('Analog', { exact: true })).toHaveCount(0); + await expect(row.getByText('HDZero', { exact: true })).toBeVisible(); + await expect(row.getByText('DJI', { exact: true })).toBeVisible(); // ── Remove (with the confirm step) ───────────────────────────────────────────────────── await row.getByRole('button', { name: 'Remove' }).click(); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 54929b0..0f3576b 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -473,7 +473,7 @@ export async function listPilots( /** * Create a pilot (`POST /pilots`) — issue #74. RD-gated (full-trust by default). The body's - * `callsign` is required; everything else (`name`, `vtx_type`, `multigp_id`, `velocidrone_id`) is + * `callsign` is required; everything else (`name`, `vtx_types`, `multigp_id`, `velocidrone_id`) is * optional. The id is auto-generated server-side. Resolves to the new {@link Pilot}, or rejects on * a non-2xx / transport failure (a missing/blank callsign is a **400**). */ From e773f81cf09d880b4ae839b832ffd18847df6d5f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 21:01:57 +0000 Subject: [PATCH 090/362] fix: don't auto-pull into the active event on hub/bare load (#118) Hash routing (#126) carried over the old #90 "on load, resume into the active event" behavior: an empty / `#/` / hub hash plus an active event resolved to the event workspace. So refreshing while on the home hub yanked the user into an event. Make the hash authoritative: `resolveInitialRoute` no longer has a hub -> workspace resume branch. An empty/hub hash always resolves to the hub regardless of whether an event is active; a workspace hash still restores that tab (reconciling to the Events page when no event is active), and a page hash still shows that page. Reload now always preserves where you are -- being outside an event stays outside; the user only enters an event by navigating in or loading a workspace URL. This intentionally supersedes #90's reload-resume per user feedback. Tests: - Unit (route.test.ts): the "empty/hub hash + active event" case now resolves to the hub (was workspace); workspace-hash and page-hash cases unchanged. - e2e (routing.spec.ts): new spec -- with an active event present, on the hub, a reload stays on the hub (no workspace). In-event reload and Pilots reload specs stay green. - e2e: since the hub no longer auto-resumes, a fresh load with an active event keeps `currentEvent` hydrated, so the Events page auto-enters the workspace. Updated race/roster/routing specs that assumed the picker to reach it robustly ("Switch event" back to the picker, or handle the auto-enter), so they no longer depend on the old buggy resume. Part of #118. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 25 +++--- frontend/apps/rd-console/src/lib/route.ts | 27 +++---- frontend/apps/rd-console/tests/route.test.ts | 10 ++- frontend/e2e/race.spec.ts | 14 +++- frontend/e2e/roster.spec.ts | 21 ++--- frontend/e2e/routing.spec.ts | 80 ++++++++++++++++++-- 6 files changed, 127 insertions(+), 50 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 824a1c1..2c6025f 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -9,8 +9,10 @@ * screens, scoped to that event); the workspace's sidebar + {@link ContextHeader} are unchanged. * The old picker-as-landing and the Timers **modal** are retired — Timers is now its own page. * - * Active-event resume (#90) is preserved: on load the session resolves the Director's active - * event and, if one is set, enters its workspace directly; otherwise the shell lands on the hub. + * On load the shell resolves the Director's active event (server state, #90) so a workspace URL + * can render, but the hash is authoritative for *which* view to show (#118): a bare/hub load + * stays on the hub and never auto-enters an event — the user only enters by navigating in or + * loading a workspace URL. * * The RD token is handled **lazily**: reads/browse need none, and a privileged action prompts for * it via {@link TokenDialog} (registered as the session's token provider). A settings/gear lets @@ -58,8 +60,9 @@ // stays client-side and can't collide. See `lib/route.ts` for the scheme + reconciliation rules. // // `route` is the *intended* view. It's reconciled against the live active event (server state) - // for rendering: a workspace route with no active event reconciles to the Events page, and an - // empty/hub hash with an active event resumes into the workspace (#90), all in `lib/route.ts`. + // for rendering: a workspace route with no active event reconciles to the Events page. The hash + // is authoritative — an empty/hub hash stays on the hub even when an event is active (it no + // longer auto-resumes into the workspace), all in `lib/route.ts`. let route = $state(parseHash(location.hash)); // Navigate to a route: this is the single mutation point. It updates `location.hash`, which (for @@ -89,12 +92,14 @@ let tokenDialog = $state(); session.setTokenProvider(() => tokenDialog?.request() ?? Promise.resolve(undefined)); - // Resume into the Director's active event on load (#90), composed with the hash route (#118): - // the active event is server-side state, so a reload reads `GET /active-event` and re-enters the - // same event. Once it settles we resolve the *initial* route from the hash + whether an event is - // active — an `#/event/` hash restores that tab, a top-level page hash shows that page, and - // an empty hash resumes into the workspace (active set) or lands on the hub. While it resolves we - // show a brief loading state. After this one-shot resume, the hash stays the source of truth. + // Resolve the *initial* route on load (#118): the active event is server-side state, so we first + // read `GET /active-event` (it determines whether a workspace hash can render), then resolve the + // route from the hash + whether an event is active. The hash is authoritative — an `#/event/` + // hash restores that tab (or reconciles to Events if no event is active), a top-level page hash + // shows that page, and an empty/hub hash stays on the hub *even when an event is active* (we no + // longer auto-resume into the workspace; this intentionally supersedes #90's reload-resume per + // user feedback). While it resolves we show a brief loading state; after this the hash is the + // source of truth. let resumed = false; $effect(() => { if (resumed) return; diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index 2c25831..b3f1941 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -96,11 +96,9 @@ export function formatHash(route: Route): string { * - A `workspace` route with **no active event** → fall back to the Events page (`#/events`), * since there is no event to show — never render a broken workspace. * - A `page` route with an **active event** is fine as-is: the user explicitly navigated to a - * top-level page (e.g. via the breadcrumb) even though an event is active, so honour it. - * - * The caller composes this with the active-event resume (#90): an empty hash + active event still - * lands in the workspace because the *default* for an empty hash, when an event is active, is the - * workspace — handled by {@link resolveInitialRoute}. + * top-level page (e.g. via the breadcrumb) even though an event is active, so honour it. This + * includes the hub: an empty/hub hash stays on the hub even when an event is active — the hash + * is authoritative, so being outside an event survives a reload. */ export function reconcileRoute(route: Route, hasActiveEvent: boolean): Route { if (route.kind === 'workspace' && !hasActiveEvent) { @@ -110,20 +108,19 @@ export function reconcileRoute(route: Route, hasActiveEvent: boolean): Route { } /** - * Resolve the route to show on **initial load**, composing the hash with the active-event resume - * (#90). The rule mirrors the legacy default while honouring an explicit hash: + * Resolve the route to show on **initial load**. The **hash is authoritative**: where you were + * survives a reload, and the shell never auto-enters an event from a bare/hub load. * - * - Empty/hub hash + active event → the workspace (resume into the active event's default tab), - * matching the pre-routing behaviour where a reload with an active event re-entered it. - * - Empty/hub hash + no active event → the hub. + * - Empty/hub hash → the hub, **regardless of whether an event is active**. (This intentionally + * supersedes the #90 "reload resumes into the active event" behaviour: being outside an event + * stays outside; the user only enters an event by navigating in or loading a workspace URL.) * - An explicit page hash (`#/pilots` etc.) → that page (honoured even if an event is active). * - A workspace hash → the workspace on that tab if an event is active, else the Events page * (via {@link reconcileRoute}). + * + * This is just the parse composed with {@link reconcileRoute}; there is no longer a special hub → + * workspace resume branch. */ export function resolveInitialRoute(hash: string, hasActiveEvent: boolean): Route { - const parsed = parseHash(hash); - const isHub = parsed.kind === 'page' && parsed.page === 'home'; - // The hub default, when an event is active, resumes into the workspace (legacy #90 behaviour). - if (isHub && hasActiveEvent) return { kind: 'workspace', tab: 'live' }; - return reconcileRoute(parsed, hasActiveEvent); + return reconcileRoute(parseHash(hash), hasActiveEvent); } diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts index dd295e7..3f1413a 100644 --- a/frontend/apps/rd-console/tests/route.test.ts +++ b/frontend/apps/rd-console/tests/route.test.ts @@ -96,10 +96,12 @@ describe('reconcileRoute', () => { }); }); -describe('resolveInitialRoute (hash composed with the active-event resume #90)', () => { - it('empty hash + active event resumes into the workspace (default live tab)', () => { - expect(resolveInitialRoute('', true)).toEqual({ kind: 'workspace', tab: 'live' }); - expect(resolveInitialRoute('#/', true)).toEqual({ kind: 'workspace', tab: 'live' }); +describe('resolveInitialRoute (hash is authoritative; #118)', () => { + // Supersedes the #90 reload-resume: a bare/hub load never auto-enters the active event. Being + // outside an event survives a reload — staying on the hub keeps you on the hub. + it('empty/hub hash + active event stays on the hub (does NOT resume into the workspace)', () => { + expect(resolveInitialRoute('', true)).toEqual({ kind: 'page', page: 'home' }); + expect(resolveInitialRoute('#/', true)).toEqual({ kind: 'page', page: 'home' }); }); it('empty hash + no active event lands on the hub', () => { diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 54d34d1..f958b96 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -190,11 +190,17 @@ test('home hub navigates to each page and back, with working breadcrumbs', async .click(); await expect(page.getByRole('heading', { name: 'Timers' })).toBeVisible(); - // Home → Events: the picker (former landing), reachable as a page now. + // Home → Events: the picker (former landing), reachable as a page now. The worker's Director may + // still have an active event from an earlier spec, in which case `currentEvent` is hydrated and + // the Events page auto-enters that event's workspace (#118); "Switch event" then returns to the + // picker. So we land on the picker whether or not an event was active. await page.getByRole('button', { name: /Events/ }).click(); - await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ - timeout: 15_000 - }); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await liveNav.isVisible().catch(() => false)) { + await page.getByRole('button', { name: /Switch event/ }).click(); + } + await expect(picker).toBeVisible({ timeout: 15_000 }); await page .getByRole('navigation', { name: 'Breadcrumb' }) .getByRole('button', { name: 'Home' }) diff --git a/frontend/e2e/roster.spec.ts b/frontend/e2e/roster.spec.ts index f98a89c..7b2f247 100644 --- a/frontend/e2e/roster.spec.ts +++ b/frontend/e2e/roster.spec.ts @@ -20,20 +20,23 @@ const CALLSIGN = `E2E-Roster-${Date.now()}`; test('RD registers a pilot inline and checks it into the event roster', async ({ page }) => { await page.goto('/'); - // ── Get into an event (Practice). The worker's Director may already be in a workspace from a - // prior spec; if we're on the hub, go Events → Practice. ─────────────────────────────────── + // ── Get into an event (Practice). The worker's Director may already have an active event from a + // prior spec. On a fresh load the hash is authoritative (#118), so we land on the hub even when + // an event is active; clicking Events then either opens the picker (no event active) or + // auto-enters the active event's workspace. Either way we end up in a workspace on Practice. ── const liveNav = page.getByRole('button', { name: /Live control/ }); const eventsCard = page.getByRole('button', { name: /Events/ }); await expect(liveNav.or(eventsCard).first()).toBeVisible({ timeout: 15_000 }); if (!(await liveNav.isVisible().catch(() => false))) { await eventsCard.click(); - await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ - timeout: 15_000 - }); - await page - .getByRole('button', { name: /Practice/ }) - .first() - .click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await picker.isVisible().catch(() => false)) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } await expect(liveNav).toBeVisible({ timeout: 15_000 }); } diff --git a/frontend/e2e/routing.spec.ts b/frontend/e2e/routing.spec.ts index b9d3287..075799c 100644 --- a/frontend/e2e/routing.spec.ts +++ b/frontend/e2e/routing.spec.ts @@ -16,7 +16,11 @@ */ import { expect, test } from './observability.js'; -/** Return to the home hub regardless of whether the shell resumed into a workspace (#90). */ +/** + * Return to the home hub. The hash is authoritative (#118) so a bare `goto('/')` lands on the hub + * directly; the workspace-resume fallback below is kept defensively in case a prior test left the + * shell mid-workspace, but it should no longer be reached on a fresh load. + */ async function gotoHub(page: import('@playwright/test').Page) { await page.goto('/'); const pilotsCard = page.getByRole('heading', { name: 'Pilots' }); @@ -31,6 +35,26 @@ async function gotoHub(page: import('@playwright/test').Page) { await expect(pilotsCard).toBeVisible({ timeout: 15_000 }); } +/** + * Open the **Events picker** ("Choose an event"). The worker's Director is shared, so an event may + * already be active server-side; on a fresh load the shell hydrates `currentEvent` from it, and the + * Events page then auto-enters that event's workspace (#118). When that happens we use "Switch + * event" to return to the picker (it clears the local event). So this lands on the picker whether or + * not an event was active. + */ +async function gotoPicker(page: import('@playwright/test').Page) { + await gotoHub(page); + await page.getByRole('button', { name: /Events/ }).click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + const liveNav = page.getByRole('button', { name: /Live control/ }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await liveNav.isVisible().catch(() => false)) { + // Auto-entered the active event's workspace — "Switch event" leaves it and lands on the picker. + await page.getByRole('button', { name: /Switch event/ }).click(); + } + await expect(picker).toBeVisible({ timeout: 15_000 }); +} + test('a refresh on the Pilots page stays on Pilots (hash reflects the view)', async ({ page }) => { await gotoHub(page); @@ -52,13 +76,8 @@ test('a refresh on the Pilots page stays on Pilots (hash reflects the view)', as test('inside an event, a refresh stays on the open tab; browser back returns to the previous view', async ({ page }) => { - await gotoHub(page); - - // Hub → Events → enter Practice → the workspace opens on the default Live tab (#/event/live). - await page.getByRole('button', { name: /Events/ }).click(); - await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ - timeout: 15_000 - }); + // Hub → Events picker → enter Practice → the workspace opens on the default Live tab (#/event/live). + await gotoPicker(page); await page .getByRole('button', { name: /Practice/ }) .first() @@ -98,3 +117,48 @@ test('inside an event, a refresh stays on the open tab; browser back returns to }); await expect.poll(() => new URL(page.url()).hash).toBe('#/event/registration'); }); + +test('on the hub with an active event, a refresh stays on the hub (no auto-resume into the event)', async ({ + page +}) => { + // First make an event ACTIVE on the Director (server state, #90): enter Practice. After this the + // Director's active event is set, which is exactly the condition that used to yank a hub reload + // into the workspace. The fix (#118) makes the hash authoritative, so it must no longer happen. + // + // The Director is worker-scoped and reused across specs, so an event may already be active when + // this spec runs. The Events page auto-enters the workspace when an event is active, so clicking + // Events lands us in the workspace either way (directly if already active, or after we pick + // Practice in the picker) — leaving the Director's active event set, which is what we need. + await gotoHub(page); + await page.getByRole('button', { name: /Events/ }).click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + const liveNav = page.getByRole('button', { name: /Live control/ }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await picker.isVisible().catch(() => false)) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + await expect.poll(() => new URL(page.url()).hash).toBe('#/event/live'); + + // Leave the event back to the hub via the breadcrumb Home crumb. `leaveEvent()` tears down the + // local workspace but does NOT clear the Director's active event (it stays live for the picker's + // Active pill), so the active-event condition persists across the reload below. Hash → canonical #/. + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + await expect(page.getByRole('heading', { name: 'Pilots' })).toBeVisible({ timeout: 15_000 }); + await expect.poll(() => new URL(page.url()).hash).toMatch(/^(#\/?)?$/); + + // Reload — the key proof: with the active event STILL set on the Director, we stay on the hub + // (hub cards visible, no workspace), rather than being auto-pulled back into the event workspace. + await page.reload(); + await expect(page.getByRole('heading', { name: 'Pilots' })).toBeVisible({ timeout: 15_000 }); + // The hub stayed: the hash is the canonical #/ (or empty), not #/event/. + expect(new URL(page.url()).hash).toMatch(/^(#\/?)?$/); + // And we are NOT in the workspace — the Live control sidebar button is absent on the hub. + await expect(page.getByRole('button', { name: /Live control/ })).toBeHidden(); +}); From f57c5ef9037f7a34c69b1ee21cc2e3ff303593e9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 21:16:58 +0000 Subject: [PATCH 091/362] docs(cloud): capture multi-cloud / federation as an open question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A FOSS, self-hostable Director shouldn't be hard-wired to one Cloud; an RD may want to federate to several Cloud instances at once. Flag the single-Cloud assumption in §1 and record the unresolved design question in §8 (Open decisions). RD feedback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/cloud.html | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/cloud.html b/docs/cloud.html index dff5e6b..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
      @@ -467,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.
    • From 1ed05e3bf2dc727b9d8eb5bb20ee3208fb1e6037 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 21 Jun 2026 21:32:17 +0000 Subject: [PATCH 092/362] Brand: GridFPV logo in the console (hub + sidebar) + favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline hexagon brand mark with the new GridFPV logo image in both the event-workspace sidebar (App.svelte) and the home-hub header (HomeHub.svelte), keeping the "GridFPV" wordmark + subtitle next to it. The logo is the rounded-square neon-"G" app icon (transparent corners), imported from src/assets/logo.png and downscaled via CSS (~30px sidebar, 48px hub) so it stays crisp on retina. The brand stays the clickable "go home" root — no selectors weakened. Set the browser favicon + apple-touch-icon to the new mark via public/{favicon-32,favicon-64,apple-touch-icon}.png in index.html, replacing the default. tsconfig.test.json gains `vite/client` types so the .png import type-checks under the test config too (src is in its include set). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/index.html | 3 +++ .../rd-console/public/apple-touch-icon.png | Bin 0 -> 50987 bytes .../apps/rd-console/public/favicon-32.png | Bin 0 -> 2385 bytes .../apps/rd-console/public/favicon-64.png | Bin 0 -> 7964 bytes frontend/apps/rd-console/src/App.svelte | 22 +++++++----------- frontend/apps/rd-console/src/assets/logo.png | Bin 0 -> 372028 bytes .../rd-console/src/screens/HomeHub.svelte | 22 +++++++----------- frontend/apps/rd-console/tsconfig.test.json | 2 +- 8 files changed, 21 insertions(+), 28 deletions(-) create mode 100644 frontend/apps/rd-console/public/apple-touch-icon.png create mode 100644 frontend/apps/rd-console/public/favicon-32.png create mode 100644 frontend/apps/rd-console/public/favicon-64.png create mode 100644 frontend/apps/rd-console/src/assets/logo.png diff --git a/frontend/apps/rd-console/index.html b/frontend/apps/rd-console/index.html index a999bba..e388ef9 100644 --- a/frontend/apps/rd-console/index.html +++ b/frontend/apps/rd-console/index.html @@ -3,6 +3,9 @@ + + + GridFPV — RD console diff --git a/frontend/apps/rd-console/public/apple-touch-icon.png b/frontend/apps/rd-console/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4d524a705a0410b894920620e55ff0ba104f2f35 GIT binary patch literal 50987 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 A5dZ)H literal 0 HcmV?d00001 diff --git a/frontend/apps/rd-console/public/favicon-32.png b/frontend/apps/rd-console/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..6c43a1dac109cd4d322110ed5db9dfc222770905 GIT binary patch literal 2385 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 SJ4)IB0000 + + {/snippet} + + + + (confirming = undefined)} + title="Remove class" +> +

      + Remove {confirming?.name} from the directory? Any event that selected it keeps running; + this only removes the directory entry. +

      + {#snippet footer()} + + + {/snippet} +
      + + diff --git a/frontend/apps/rd-console/src/screens/ClassesPage.svelte b/frontend/apps/rd-console/src/screens/ClassesPage.svelte new file mode 100644 index 0000000..d21b989 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/ClassesPage.svelte @@ -0,0 +1,95 @@ + + +
      +
      + + +
      +
      +

      Classes

      +

      + The application-level class directory — maintained once here, selected per event. Each + class needs only a name; the rest (source, reference, description) is + optional. Add a standard MultiGP class with one tap from the add form. +

      +
      + +
      + + +
      + +
      +
      +
      +
      + + diff --git a/frontend/apps/rd-console/src/screens/EventClasses.svelte b/frontend/apps/rd-console/src/screens/EventClasses.svelte new file mode 100644 index 0000000..731afc3 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventClasses.svelte @@ -0,0 +1,192 @@ + + +
      + + {#snippet actions()} + + {/snippet} + + +

      + {orderedSelection.length} of {classes.length} + {classes.length === 1 ? 'class' : 'classes'} selected for this event +

      + + selected.has(c.id)} + > + {#snippet rowLead(cls)} + toggle(cls.id)} + aria-label={`Select ${cls.name}`} + /> + {/snippet} + + {#snippet listFooter()} +
      + + {orderedSelection.length} selected + +
      + {#if changed} + + {/if} + +
      +
      + {/snippet} +
      +
      +
      + + diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte index edd886c..36ae383 100644 --- a/frontend/apps/rd-console/src/screens/HomeHub.svelte +++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte @@ -19,11 +19,13 @@ let { session, onpilots, + onclasses, onevents, ontimers }: { session: Session; onpilots: () => void; + onclasses: () => void; onevents: () => void; ontimers: () => void; } = $props(); @@ -31,6 +33,7 @@ // Each summary loads independently and best-effort; `undefined` while loading, `null` on a // failed/unreachable read (rendered as a dash). The hub renders immediately regardless. let pilotCount = $state(undefined); + let classCount = $state(undefined); let eventCount = $state(undefined); let timerCount = $state(undefined); let connectedTimers = $state(undefined); @@ -45,6 +48,10 @@ .listPilots() .then((pilots) => (pilotCount = pilots.length)) .catch(() => (pilotCount = null)); + void session + .listClasses() + .then((classes) => (classCount = classes.length)) + .catch(() => (classCount = null)); void session .listEvents() .then((events) => (eventCount = events.length)) @@ -112,6 +119,37 @@ + + diff --git a/frontend/apps/rd-console/tests/ClassManager.test.ts b/frontend/apps/rd-console/tests/ClassManager.test.ts index 5d98e87..16b4cd1 100644 --- a/frontend/apps/rd-console/tests/ClassManager.test.ts +++ b/frontend/apps/rd-console/tests/ClassManager.test.ts @@ -5,13 +5,16 @@ import type { Class } from '@gridfpv/types'; import ClassManager from '../src/screens/ClassManager.svelte'; import { makeTestSession } from './support.js'; +/** A locked, fixed-id built-in (read-only: no Edit/Remove, shows a lock + org badge). */ const OPEN: Class = { - id: 'c1', - name: 'Open', + id: 'mgp-open', + name: 'Open Class', source: 'MultiGP', - reference: 'https://multigp.com/open', - description: 'Unlimited.' + reference: 'https://www.multigp.com/class-specifications/', + description: 'Unlimited.', + builtin: true }; +/** A user-created Custom class (full CRUD). */ const HOUSE: Class = { id: 'c2', name: 'House', source: 'Custom' }; /** Open the Add form via the manager's exported `openAdd()` (the page button calls it). */ @@ -21,20 +24,31 @@ async function openAdd(manager: { openAdd: () => void }) { } describe('ClassManager (#84)', () => { - it('lists the directory with name, a source badge, reference link, and per-row controls', async () => { + it('lists built-ins (locked, org badge, no Edit/Remove) and Custom rows (full controls)', async () => { const listClassesImpl = vi.fn(async () => [OPEN, HOUSE]); const { session } = makeTestSession({ noEnter: true, listClassesImpl }); render(ClassManager, { session }); - await screen.findByText('Open'); + await screen.findByText('Open Class'); const list = screen.getByRole('list', { name: 'Class directory' }); const rows = within(list).getAllByRole('listitem'); expect(rows).toHaveLength(2); - // Open shows its MultiGP source badge + a reference link; House has neither extra. + + // The built-in shows its MultiGP org badge + a reference link + a built-in/lock indicator, and + // has NO Edit/Remove (it is read-only). expect(within(rows[0]).getByText('MultiGP')).toBeInTheDocument(); - expect(within(rows[0]).getByRole('link', { name: 'Reference for Open' })).toBeInTheDocument(); + expect( + within(rows[0]).getByRole('link', { name: 'Reference for Open Class' }) + ).toBeInTheDocument(); + expect(within(rows[0]).getByLabelText('Built-in')).toBeInTheDocument(); + expect(within(rows[0]).queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + expect(within(rows[0]).queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + + // The Custom row shows its badge and keeps full Edit/Remove controls. expect(within(rows[1]).getByText('Custom')).toBeInTheDocument(); - expect(within(rows[1]).queryByRole('link')).not.toBeInTheDocument(); + expect(within(rows[1]).queryByLabelText('Built-in')).not.toBeInTheDocument(); + expect(within(rows[1]).getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(within(rows[1]).getByRole('button', { name: 'Remove' })).toBeInTheDocument(); }); it('shows the empty state when the directory has no classes', async () => { @@ -57,12 +71,12 @@ describe('ClassManager (#84)', () => { expect(createClassImpl).not.toHaveBeenCalled(); }); - it('creates a class with name + source + reference + description', async () => { + it('creates a Custom class (no source picker — always Custom) with name + reference + description', async () => { let calls = 0; const created: Class = { id: 'c9', name: 'Spec', - source: 'Other', + source: 'Custom', reference: 'ref-1', description: 'A spec class.' }; @@ -73,8 +87,11 @@ describe('ClassManager (#84)', () => { await screen.findByText(/No classes/i); await openAdd(component as unknown as { openAdd: () => void }); + // The add form offers no org source picker — new classes are always Custom. + expect(screen.queryByLabelText('Source')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add from MultiGP')).not.toBeInTheDocument(); + await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Spec' } }); - await fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'Other' } }); await fireEvent.input(screen.getByLabelText('Reference'), { target: { value: 'ref-1' } }); await fireEvent.input(screen.getByLabelText('Description'), { target: { value: 'A spec class.' } @@ -85,50 +102,30 @@ describe('ClassManager (#84)', () => { await waitFor(() => expect(createClassImpl).toHaveBeenCalledTimes(1)); expect(createClassImpl).toHaveBeenCalledWith( 'http://d.local', - { name: 'Spec', source: 'Other', reference: 'ref-1', description: 'A spec class.' }, + { name: 'Spec', source: 'Custom', reference: 'ref-1', description: 'A spec class.' }, 'tok' ); await screen.findByText('Spec'); }); - it('pre-fills the form from the MultiGP quick-pick (source=MultiGP + reference)', async () => { - let calls = 0; - const created: Class = { id: 'c9', name: 'Tiny Whoop', source: 'MultiGP', reference: 'url' }; - const listClassesImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); - const createClassImpl = vi.fn(async () => created); - const { session } = makeTestSession({ noEnter: true, listClassesImpl, createClassImpl }); - const { component } = render(ClassManager, { session }); - await screen.findByText(/No classes/i); - await openAdd(component as unknown as { openAdd: () => void }); - - // Pick a MultiGP class from the quick-pick select; the form fields fill in. - await fireEvent.change(screen.getByLabelText('Add from MultiGP'), { - target: { value: 'Tiny Whoop' } - }); - expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Tiny Whoop'); - expect((screen.getByLabelText('Source') as HTMLSelectElement).value).toBe('MultiGP'); - expect((screen.getByLabelText('Reference') as HTMLInputElement).value).toMatch(/^https?:\/\//); - - await fireEvent.click(screen.getByRole('button', { name: 'Add class' })); - await waitFor(() => expect(createClassImpl).toHaveBeenCalledTimes(1)); - expect(createClassImpl).toHaveBeenCalledWith( - 'http://d.local', - expect.objectContaining({ name: 'Tiny Whoop', source: 'MultiGP' }), - 'tok' - ); - }); - - it('edits a class and CLEARS reference + description, sending null for each', async () => { - const listClassesImpl = vi.fn(async () => [OPEN]); + it('edits a Custom class and CLEARS reference + description, sending null for each', async () => { + const CUSTOM: Class = { + id: 'c1', + name: 'House', + source: 'Custom', + reference: 'ref', + description: 'notes' + }; + const listClassesImpl = vi.fn(async () => [CUSTOM]); const updateClassImpl = vi.fn(async () => ({ - ...OPEN, + ...CUSTOM, reference: undefined, description: undefined })); const { session } = makeTestSession({ noEnter: true, listClassesImpl, updateClassImpl }); render(ClassManager, { session }); - await screen.findByText('Open'); + await screen.findByText('House'); await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); await screen.findByRole('form', { name: 'Edit class' }); @@ -146,22 +143,23 @@ describe('ClassManager (#84)', () => { ); }); - it('removes a class after a confirm step', async () => { + it('removes a Custom class after a confirm step', async () => { const listClassesImpl = vi.fn(async () => [OPEN, HOUSE]); const deleteClassImpl = vi.fn(async () => undefined as unknown as void); const { session } = makeTestSession({ noEnter: true, listClassesImpl, deleteClassImpl }); render(ClassManager, { session }); - await screen.findByText('Open'); + await screen.findByText('House'); const list = screen.getByRole('list', { name: 'Class directory' }); - const openRow = within(list).getAllByRole('listitem')[0]; - await fireEvent.click(within(openRow).getByRole('button', { name: 'Remove' })); + // The Custom row (index 1) is the removable one; the built-in (index 0) has no Remove. + const houseRow = within(list).getAllByRole('listitem')[1]; + await fireEvent.click(within(houseRow).getByRole('button', { name: 'Remove' })); await screen.findByText(/Remove class/i); const dialogs = screen.getAllByRole('button', { name: 'Remove' }); await fireEvent.click(dialogs[dialogs.length - 1]); await waitFor(() => expect(deleteClassImpl).toHaveBeenCalledTimes(1)); - expect(deleteClassImpl).toHaveBeenCalledWith('http://d.local', 'c1', 'tok'); + expect(deleteClassImpl).toHaveBeenCalledWith('http://d.local', 'c2', 'tok'); }); }); diff --git a/frontend/apps/rd-console/tests/classes.test.ts b/frontend/apps/rd-console/tests/classes.test.ts index 646b7fe..97b3be7 100644 --- a/frontend/apps/rd-console/tests/classes.test.ts +++ b/frontend/apps/rd-console/tests/classes.test.ts @@ -1,44 +1,52 @@ import { describe, expect, it } from 'vitest'; import type { Class } from '@gridfpv/types'; import { - MULTIGP_CLASSES, buildCreateRequest, buildUpdateRequest, emptyForm, formFromClass, - formFromMultiGp, + isBuiltin, + sourceTone, type ClassFormValues } from '../src/lib/classes.js'; -/** A class with every field populated — the baseline for the edit/diff tests. */ +/** A custom class with every field populated — the baseline for the edit/diff tests. */ const FULL: Class = { id: 'c1', - name: 'Open', + name: 'House Spec', + source: 'Custom', + reference: 'https://example.com/spec', + description: 'The house spec class.' +}; + +/** A locked, fixed-id built-in (the server flags these with `builtin: true`). */ +const BUILTIN: Class = { + id: 'mgp-open', + name: 'Open Class', source: 'MultiGP', - reference: 'https://multigp.com/open', - description: 'The unlimited class.' + reference: 'https://www.multigp.com/class-specifications/', + description: 'Open/unlimited 5–6" class.', + builtin: true }; -describe('buildCreateRequest', () => { - it('sends the name + source + filled optionals (blank optionals omitted)', () => { +describe('buildCreateRequest (create = Custom only)', () => { + it('always sends source=Custom + the name + filled optionals (blank optionals omitted)', () => { const v = emptyForm(); v.name = ' Spec '; - v.source = 'MultiGP'; v.reference = 'mg-spec'; const req = buildCreateRequest(v); - expect(req).toEqual({ name: 'Spec', source: 'MultiGP', reference: 'mg-spec' }); - // Untouched optionals are absent (not empty strings). + expect(req).toEqual({ name: 'Spec', source: 'Custom', reference: 'mg-spec' }); expect(req).not.toHaveProperty('description'); }); - it('defaults source to Custom on a blank add form', () => { + it('defaults to a Custom class on a blank add form', () => { const v = emptyForm(); v.name = 'House'; expect(buildCreateRequest(v)).toEqual({ name: 'House', source: 'Custom' }); }); }); -describe('buildUpdateRequest — clear-via-null', () => { +describe('buildUpdateRequest — clear-via-null (no source field)', () => { function valuesFrom(c: Class): ClassFormValues { return formFromClass(c); } @@ -54,16 +62,13 @@ describe('buildUpdateRequest — clear-via-null', () => { const req = buildUpdateRequest(FULL, v); expect(req.reference).toBeNull(); expect(req.description).toBeNull(); - // Unchanged fields stay omitted. expect(req).not.toHaveProperty('name'); - expect(req).not.toHaveProperty('source'); }); - it('sets a changed name + source and leaves others untouched', () => { + it('sets a changed name and leaves others untouched', () => { const v = valuesFrom(FULL); - v.name = 'Pro Open'; - v.source = 'Custom'; - expect(buildUpdateRequest(FULL, v)).toEqual({ name: 'Pro Open', source: 'Custom' }); + v.name = 'Pro House'; + expect(buildUpdateRequest(FULL, v)).toEqual({ name: 'Pro House' }); }); it('never clears the name when blanked', () => { @@ -72,42 +77,27 @@ describe('buildUpdateRequest — clear-via-null', () => { expect(buildUpdateRequest(FULL, v)).not.toHaveProperty('name'); }); - it('sets a changed reference value', () => { + it('never emits a source (the create/edit forms no longer pick a source)', () => { const v = valuesFrom(FULL); - v.reference = 'https://multigp.com/open-2'; - expect(buildUpdateRequest(FULL, v).reference).toBe('https://multigp.com/open-2'); + v.name = 'Renamed'; + expect(buildUpdateRequest(FULL, v)).not.toHaveProperty('source'); }); }); -describe('MultiGP quick-pick', () => { - it('lists the seven standard MultiGP classes, each with a reference', () => { - expect(MULTIGP_CLASSES.map((p) => p.name)).toEqual([ - 'Spec', - 'Pro Spec', - 'Freedom Spec', - 'Street League', - 'Open', - 'Tiny Whoop', - 'Tiny Trainer' - ]); - for (const preset of MULTIGP_CLASSES) { - expect(preset.reference).toMatch(/^https?:\/\//); - } +describe('isBuiltin + source badges', () => { + it('flags only built-in classes', () => { + expect(isBuiltin(BUILTIN)).toBe(true); + expect(isBuiltin(FULL)).toBe(false); + expect(isBuiltin({ ...FULL, builtin: false })).toBe(false); }); - it('formFromMultiGp pre-fills source=MultiGP + name + reference', () => { - const form = formFromMultiGp(MULTIGP_CLASSES[0]); - expect(form).toEqual({ - name: 'Spec', - source: 'MultiGP', - reference: MULTIGP_CLASSES[0].reference, - description: '' - }); - // It maps straight to a create body carrying the MultiGP provenance + reference. - expect(buildCreateRequest(form)).toEqual({ - name: 'Spec', - source: 'MultiGP', - reference: MULTIGP_CLASSES[0].reference - }); + it('gives each org source a distinct badge tone, with Custom/Other neutral', () => { + expect(sourceTone('MultiGP')).toBe('accent'); + expect(sourceTone('Five33')).toBe('success'); + expect(sourceTone('FreedomSpec')).toBe('info'); + expect(sourceTone('StreetLeague')).toBe('warn'); + expect(sourceTone('UDL')).toBe('danger'); + expect(sourceTone('Custom')).toBe('neutral'); + expect(sourceTone('Other')).toBe('neutral'); }); }); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index ce8b0c8..bddb9b7 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -694,12 +694,15 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { * the `ClassId`/`OptionalEdit` types are reused). Rounds / the phase engine are NOT in this slice. * * guards: - * - `GET /classes` is an **open read** → `Class[]` (empty on a fresh Director — no built-in class). + * - `GET /classes` is an **open read** → `Class[]` carrying the 9 locked, fixed-id **built-in** + * classes (present on every Director, flagged `builtin`, carrying their real org as `source`). * - `POST /classes` is **RD-gated** (no/bad token → 401), requires a `name`, auto-generates an id, * carries the `source`/`reference`/`description` metadata, and the new class appears in the listing. - * - `PUT /classes/{id}` edits (set / clear via wire-`null` — the `OptionalEdit` three-state). + * - `PUT/DELETE /classes/{id}` on a **built-in** id is rejected (read-only); user/Custom classes are + * full CRUD (set / clear via wire-`null` — the `OptionalEdit` three-state). * - `PUT /events/{id}/classes` is **RD-gated**, validates each id names a directory class (unknown → - * 404 `UnknownScope`), and records the selection on the event's `EventMeta.classes` (empty default). + * 404 `UnknownScope`), and records the selection on the event's `EventMeta.classes` (empty default); + * a built-in id is selectable like any class. */ describe('seam 12: application-level classes + per-event selection (#84)', () => { /** `GET /classes` → the parsed `Class[]` (asserting a 200, open read). */ @@ -752,9 +755,54 @@ describe('seam 12: application-level classes + per-event selection (#84)', () => return { status: res.status, body: parsed }; } - it('GET /classes is an open read (empty on a fresh Director — no built-in class)', async () => { + it('GET /classes is an open read carrying the 9 fixed-id built-ins (org-sourced, flagged builtin)', async () => { const classes = await listClasses(); expect(Array.isArray(classes)).toBe(true); + + // The 9 canonical built-ins are present with their fixed ids — identical on every Director. + const byId = new Map(classes.map((c) => [c.id, c])); + const fixedIds = [ + 'mgp-open', + 'mgp-pro-spec', + 'mgp-whoop', + 'mgp-micro', + 'five33-tiny-trainer', + 'freedom-spec', + 'street-league', + 'udl-igniter', + 'udl-shrieker' + ]; + for (const id of fixedIds) { + const cls = byId.get(id); + expect(cls, `built-in ${id} present`).toBeDefined(); + expect(cls?.builtin).toBe(true); + } + // They carry their real org as the source (a badge), not Custom. + expect(byId.get('mgp-open')?.source).toBe('MultiGP'); + expect(byId.get('five33-tiny-trainer')?.source).toBe('Five33'); + expect(byId.get('freedom-spec')?.source).toBe('FreedomSpec'); + expect(byId.get('street-league')?.source).toBe('StreetLeague'); + expect(byId.get('udl-igniter')?.source).toBe('UDL'); + }); + + it('PUT/DELETE on a built-in class is rejected — built-ins are read-only', async () => { + const put = await fetch(`${director.baseUrl}/classes/mgp-open`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ name: 'Hacked' }) + }); + expect(put.status).toBe(400); + + const del = await fetch(`${director.baseUrl}/classes/mgp-open`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${TOKEN}` } + }); + expect(del.status).toBe(400); + + // The built-in is untouched. + const stillThere = (await listClasses()).find((c) => c.id === 'mgp-open'); + expect(stillThere?.name).toBe('Open Class'); + expect(stillThere?.builtin).toBe(true); }); it('POST /classes requires the RD token — no/bad token → 401', async () => { diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index 7a742f9..f596597 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -1,27 +1,26 @@ /** * Class registry lifecycle through the console UI (#84) — the deliverable proof for the class - * registry slice. + * registry slice with its locked, fixed-id built-ins. * * Two real click-throughs against a **real** Director (open / no token, full-trust by default): * - * 1. **Directory CRUD** — open the console, land on the **home hub**, open the **Classes** page, - * **add** a class via the **MultiGP quick-pick** (which pre-fills `source = MultiGP` + a - * reference) and see it listed with its source badge + reference link; **edit** it and **clear** - * the reference + description (the clear-via-`null` wiring), confirming they actually clear; then - * **remove** it. - * 2. **In-event selection** — enter an event (Practice), open the workspace's **Classes** tab, - * register a class inline, **check it** into this event's selection and **Save**; a reload - * resumes into the event with the class still selected. Cleans up after itself (uncheck + remove) - * since the worker's Director is shared. + * 1. **Built-ins + Custom CRUD** — open the console, land on the **home hub**, open the **Classes** + * page, and confirm the 9 standard **built-in** classes are listed with org source badges and a + * built-in/lock indicator, and are **not removable** (no Remove button). Then **add** a class: + * the form has no org source picker (new classes are `Custom`), and the created row shows the + * `Custom` badge and keeps Edit/Remove; finally **remove** it. + * 2. **In-event selection of a built-in** — enter an event (Practice), open the workspace's + * **Classes** tab, **check a built-in** (`Open Class`) into this event's selection and **Save**; + * a reload resumes into the event with the built-in still selected. Cleans up (uncheck + save). * - * Every step is a real click/input in headless chromium on the real `POST/PUT/DELETE /classes` + + * Every step is a real click/input in headless chromium on the real `POST/DELETE /classes` + * `PUT /events/{id}/classes` paths — nothing mocked. Importing `test`/`expect` from * `./observability.js` means a failure carries the full-stack dump (browser console, page errors, * the Director's server log). */ import { expect, test } from './observability.js'; -test('RD adds (via MultiGP quick-pick), edits (clearing reference + description), and removes a directory class', async ({ +test('Classes page lists the locked built-ins (org badges, not removable) and full CRUD on a Custom class', async ({ page }) => { const NAME = `E2E-Class-${Date.now()}`; @@ -45,66 +44,51 @@ test('RD adds (via MultiGP quick-pick), edits (clearing reference + description) timeout: 15_000 }); - // ── Add a class via the MultiGP quick-pick, then override the name to a unique one ─────── + // ── The 9 built-ins are present, org-badged, locked (no Remove) ────────────────────────── + const list = page.getByRole('list', { name: 'Class directory' }); + const openBuiltin = list.getByRole('listitem').filter({ hasText: 'Open Class' }); + await expect(openBuiltin).toBeVisible({ timeout: 15_000 }); + // It carries its real org as a badge + a built-in/lock indicator… + await expect(openBuiltin.getByText('MultiGP', { exact: true })).toBeVisible(); + await expect(openBuiltin.getByLabel('Built-in')).toBeVisible(); + // …and is read-only: no Edit / Remove on a built-in row. + await expect(openBuiltin.getByRole('button', { name: 'Remove' })).toHaveCount(0); + await expect(openBuiltin.getByRole('button', { name: 'Edit' })).toHaveCount(0); + // A couple more of the 9 are listed, across orgs. + await expect(list.getByRole('listitem').filter({ hasText: 'Tiny Trainer' })).toBeVisible(); + await expect(list.getByRole('listitem').filter({ hasText: 'Street League' })).toBeVisible(); + + // ── Add a class — the form offers NO org source picker; new classes are Custom ─────────── await page.getByRole('button', { name: '+ Add class' }).click(); const addForm = page.getByRole('form', { name: 'Add class' }); await expect(addForm).toBeVisible(); - - // Pick "Open" from the MultiGP quick-pick: this pre-fills source=MultiGP + a reference URL. - await addForm.getByLabel('Add from MultiGP').selectOption('Open'); - await expect(addForm.getByLabel('Source')).toHaveValue('MultiGP'); - await expect(addForm.getByLabel('Reference')).toHaveValue(/^https?:\/\//); - // Use a unique name so the shared Director stays isolated; add a description. + await expect(addForm.getByLabel('Source')).toHaveCount(0); + await expect(addForm.getByLabel('Add from MultiGP')).toHaveCount(0); await addForm.getByLabel('Name').fill(NAME); - await addForm.getByLabel('Description').fill('A quick-picked MultiGP class.'); - // Submit (open Director — no token prompt). `exact` so it picks the dialog's submit, not the - // page header's "+ Add class". await page.getByRole('button', { name: 'Add class', exact: true }).click(); await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - // It appears in the directory with its name, a MultiGP source badge, and a reference link. - const list = page.getByRole('list', { name: 'Class directory' }); + // The created class appears as a Custom row with full Edit/Remove controls. const row = list.getByRole('listitem').filter({ hasText: NAME }); await expect(row).toBeVisible({ timeout: 15_000 }); - await expect(row.getByText('MultiGP', { exact: true })).toBeVisible(); - await expect(row.getByRole('link', { name: `Reference for ${NAME}` })).toBeVisible(); - await expect(row.getByText('A quick-picked MultiGP class.')).toBeVisible(); - - // ── Edit: clear the reference + description ─────────────────────────────────────────────── - await row.getByRole('button', { name: 'Edit' }).click(); - const editForm = page.getByRole('form', { name: 'Edit class' }); - await expect(editForm).toBeVisible(); - await editForm.getByLabel('Reference').fill(''); - await editForm.getByLabel('Description').fill(''); - await page.getByRole('button', { name: 'Save changes' }).click(); - - // The row no longer renders a reference link or the description — the clear actually persisted - // (the edit sent `null` for reference/description; re-reading the directory shows them gone). - await expect(row.getByRole('link', { name: `Reference for ${NAME}` })).toHaveCount(0, { - timeout: 15_000 - }); - await expect(row.getByText('A quick-picked MultiGP class.')).toHaveCount(0); - // The name + source badge are unchanged (only reference/description were cleared). - await expect(row.getByText(NAME)).toBeVisible(); - await expect(row.getByText('MultiGP', { exact: true })).toBeVisible(); + await expect(row.getByText('Custom', { exact: true })).toBeVisible(); + await expect(row.getByLabel('Built-in')).toHaveCount(0); + await expect(row.getByRole('button', { name: 'Edit' })).toBeVisible(); - // ── Remove (with the confirm step) ───────────────────────────────────────────────────── + // ── Remove the Custom class (with the confirm step) ────────────────────────────────────── await row.getByRole('button', { name: 'Remove' }).click(); const confirm = page.getByRole('dialog').filter({ hasText: 'Remove class' }); await expect(confirm).toBeVisible(); await confirm.getByRole('button', { name: 'Remove' }).click(); - - // The class is gone from the directory. await expect(list.getByRole('listitem').filter({ hasText: NAME })).toHaveCount(0, { timeout: 15_000 }); + // The built-in is still there afterward. + await expect(list.getByRole('listitem').filter({ hasText: 'Open Class' })).toBeVisible(); }); -test('RD registers a class inline and checks it into the event class selection', async ({ - page -}) => { - const NAME = `E2E-EventClass-${Date.now()}`; +test('RD selects a built-in class onto the event and it persists', async ({ page }) => { await page.goto('/'); // ── Get into an event (Practice). The worker's Director may already have an active event from a @@ -135,35 +119,23 @@ test('RD registers a class inline and checks it into the event class selection', await expect(page.getByRole('heading', { name: 'Classes for this event' })).toBeVisible({ timeout: 15_000 }); - // The selection count header is present. await expect(page.getByText(/selected for this event/i)).toBeVisible(); - // ── Register a brand-new class inline — without leaving the event ──────────────────────────── - await page.getByRole('button', { name: '+ Add class' }).click(); - const addForm = page.getByRole('form', { name: 'Add class' }); - await expect(addForm).toBeVisible(); - await addForm.getByLabel('Name').fill(NAME); - await page.getByRole('button', { name: 'Add class', exact: true }).click(); - await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - - // The new class appears in the list as a fresh, unchecked, selectable row. + // ── A built-in is selectable like any class: check "Open Class" into the event, then Save ──── const list = page.getByRole('list', { name: 'Class directory' }); - const row = list.getByRole('listitem').filter({ hasText: NAME }); + const row = list.getByRole('listitem').filter({ hasText: 'Open Class' }); await expect(row).toBeVisible({ timeout: 15_000 }); - const box = row.getByRole('checkbox', { name: `Select ${NAME}` }); - await expect(box).not.toBeChecked(); - - // ── Check it into the event's class selection, then Save ───────────────────────────────────── - await box.check(); + const box = row.getByRole('checkbox', { name: 'Select Open Class' }); + // Start from a known state (a prior run may have left it checked); ensure checked. + if (!(await box.isChecked())) await box.check(); await expect(box).toBeChecked(); await page.getByRole('button', { name: 'Save classes' }).click(); await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - // After a successful save there is nothing pending, so Save goes disabled again. await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ timeout: 15_000 }); - // ── It persisted on the Director: a reload resumes into the event with the class still checked. + // ── It persisted on the Director: a reload resumes into the event with the built-in checked ── await page.reload(); await expect(liveNav).toBeVisible({ timeout: 15_000 }); await page @@ -173,28 +145,17 @@ test('RD registers a class inline and checks it into the event class selection', const rowAfter = page .getByRole('list', { name: 'Class directory' }) .getByRole('listitem') - .filter({ hasText: NAME }); - await expect(rowAfter.getByRole('checkbox', { name: `Select ${NAME}` })).toBeChecked({ + .filter({ hasText: 'Open Class' }); + await expect(rowAfter.getByRole('checkbox', { name: 'Select Open Class' })).toBeChecked({ timeout: 15_000 }); - // ── Uncheck + Save: the selection shrinks back ─────────────────────────────────────────────── - const boxAfter = rowAfter.getByRole('checkbox', { name: `Select ${NAME}` }); + // ── Clean up: uncheck + Save so the shared Director's event goes back to an empty selection ── + const boxAfter = rowAfter.getByRole('checkbox', { name: 'Select Open Class' }); await boxAfter.uncheck(); await expect(boxAfter).not.toBeChecked(); await page.getByRole('button', { name: 'Save classes' }).click(); await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ timeout: 15_000 }); - - // ── Clean up: remove the class from the directory (the worker's Director is shared) ────────── - await rowAfter.getByRole('button', { name: 'Remove' }).click(); - const confirm = page.getByRole('dialog').filter({ hasText: 'Remove class' }); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: 'Remove' }).click(); - await expect( - page.getByRole('list', { name: 'Class directory' }).getByRole('listitem').filter({ - hasText: NAME - }) - ).toHaveCount(0, { timeout: 15_000 }); }); From f7ffebc25da1dc7d2ac975cb4e79a2df102e09b1 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 01:11:07 +0000 Subject: [PATCH 097/362] Classes: give the Custom source badge a color (violet, not gray) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Custom class source — the classes RDs create themselves — read as a neutral gray badge, indistinguishable from Other and easy to overlook in the directory next to the colored org built-ins. Give Custom its own violet tone: a "your own / user-defined" hue that reads apart from the org tones (MultiGP green, FreedomSpec blue, StreetLeague amber, UDL red) without clashing. Add a `violet` tone to the Badge primitive backed by new semantic `--gf-violet` / `--gf-violet-soft` tokens (dark + light), reusing the existing violet ramp that the Scored heat phase already uses, then point Custom's source tone at it. Tasteful on dark: translucent violet wash + violet text, matching the other Badge tones. Other stays neutral. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/lib/classes.ts | 8 +++++--- frontend/apps/rd-console/tests/classes.test.ts | 4 ++-- frontend/packages/components/src/primitives/Badge.svelte | 6 +++++- frontend/packages/components/src/tokens.css | 8 ++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/apps/rd-console/src/lib/classes.ts b/frontend/apps/rd-console/src/lib/classes.ts index 38c245a..cabb8d8 100644 --- a/frontend/apps/rd-console/src/lib/classes.ts +++ b/frontend/apps/rd-console/src/lib/classes.ts @@ -14,11 +14,13 @@ import type { Class, ClassSource, CreateClassRequest, UpdateClassRequest } from '@gridfpv/types'; /** The Badge tones used for class sources (a subset of the Badge component's `tone` union). */ -type SourceTone = 'accent' | 'info' | 'success' | 'warn' | 'danger' | 'neutral'; +type SourceTone = 'accent' | 'info' | 'success' | 'warn' | 'danger' | 'violet' | 'neutral'; /** * The Badge `tone` for each class source — a distinct tone per org so the built-in classes read at - * a glance, plus `Custom`/`Other` for user classes. + * a glance. `Custom` (the classes users create) gets its own **violet** tone — a "your own / + * user-defined" color that reads apart from the org tones (green/blue/amber/red) without clashing. + * `Other` stays neutral. */ const SOURCE_TONES: Record = { MultiGP: 'accent', @@ -26,7 +28,7 @@ const SOURCE_TONES: Record = { FreedomSpec: 'info', StreetLeague: 'warn', UDL: 'danger', - Custom: 'neutral', + Custom: 'violet', Other: 'neutral' }; diff --git a/frontend/apps/rd-console/tests/classes.test.ts b/frontend/apps/rd-console/tests/classes.test.ts index 97b3be7..cefeae9 100644 --- a/frontend/apps/rd-console/tests/classes.test.ts +++ b/frontend/apps/rd-console/tests/classes.test.ts @@ -91,13 +91,13 @@ describe('isBuiltin + source badges', () => { expect(isBuiltin({ ...FULL, builtin: false })).toBe(false); }); - it('gives each org source a distinct badge tone, with Custom/Other neutral', () => { + it('gives each org source a distinct badge tone, Custom its own violet, Other neutral', () => { expect(sourceTone('MultiGP')).toBe('accent'); expect(sourceTone('Five33')).toBe('success'); expect(sourceTone('FreedomSpec')).toBe('info'); expect(sourceTone('StreetLeague')).toBe('warn'); expect(sourceTone('UDL')).toBe('danger'); - expect(sourceTone('Custom')).toBe('neutral'); + expect(sourceTone('Custom')).toBe('violet'); expect(sourceTone('Other')).toBe('neutral'); }); }); diff --git a/frontend/packages/components/src/primitives/Badge.svelte b/frontend/packages/components/src/primitives/Badge.svelte index 52dacb4..66c2d9b 100644 --- a/frontend/packages/components/src/primitives/Badge.svelte +++ b/frontend/packages/components/src/primitives/Badge.svelte @@ -12,7 +12,7 @@ dot = false, children }: { - tone?: 'neutral' | 'accent' | 'success' | 'warn' | 'danger' | 'info'; + tone?: 'neutral' | 'accent' | 'success' | 'warn' | 'danger' | 'info' | 'violet'; variant?: 'soft' | 'solid' | 'outline'; dot?: boolean; children: Snippet; @@ -59,6 +59,10 @@ --_c: var(--gf-info); --_soft: var(--gf-info-soft); } + .gf-badge[data-tone='violet'] { + --_c: var(--gf-violet); + --_soft: var(--gf-violet-soft); + } .gf-badge[data-variant='soft'] { background: var(--_soft); diff --git a/frontend/packages/components/src/tokens.css b/frontend/packages/components/src/tokens.css index 6e08523..281dd54 100644 --- a/frontend/packages/components/src/tokens.css +++ b/frontend/packages/components/src/tokens.css @@ -152,6 +152,12 @@ --gf-info: var(--gf-blue-400); --gf-info-soft: color-mix(in srgb, var(--gf-blue-400) 16%, transparent); + /* Violet accent — a distinct "user-defined / your own" hue, used for the + * Custom class source badge so it reads apart from the org tones + * (green/blue/amber/red). Shares the violet ramp with the Scored phase. */ + --gf-violet: var(--gf-violet-400); + --gf-violet-soft: color-mix(in srgb, var(--gf-violet-400) 16%, transparent); + /* Legacy state aliases. */ --gf-color-live: var(--gf-success); --gf-color-leader: var(--gf-accent); @@ -282,6 +288,8 @@ --gf-warn: var(--gf-amber-500); --gf-danger: var(--gf-red-500); --gf-info: var(--gf-blue-500); + --gf-violet: var(--gf-violet-500); + --gf-violet-soft: color-mix(in srgb, var(--gf-violet-500) 12%, transparent); /* Practice accent (#91): a touch deeper so it holds contrast on a white canvas. */ --gf-accent-practice: #e8602a; From dba4bbc5bca1663843d7dfa90f3d80c336cf5a08 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 01:11:32 +0000 Subject: [PATCH 098/362] Home hub: colored GridFPV wordmark + prominent full-width Events card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the wordmark as "Grid" + "FPV" in the brand green (--gf-brand-500) in both the hub brand header (HomeHub.svelte) and the workspace sidebar brand (App.svelte) so they match the logo lockup. Restructure the hub layout: Events is now the primary action — a single full-width, visually prominent card (larger icon/title/count + accent hairline) on top, with Pilots, Classes, and Timers as a smaller three-up row beneath (wrapping on narrow widths). The two rows are stacked via a flex column on `.cards`, so flipping Events to the bottom later is a one-line `flex-direction` change. Each card keeps its icon, title, count/summary, and click-to-navigate; the four destinations + counts are unchanged. Hub unit tests and the hub-nav e2e spec pass unmodified (role/text selectors held). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 5 +- .../rd-console/src/screens/HomeHub.svelte | 261 +++++++++++------- 2 files changed, 161 insertions(+), 105 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index f5e1496..6bd5f0e 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -273,7 +273,7 @@ /> - GridFPV + GridFPV RD Console @@ -509,6 +509,9 @@ font-size: var(--gf-font-size-md); letter-spacing: var(--gf-tracking-tight); } + .wordmark .brand-fpv { + color: var(--gf-brand-500); + } .wordmark .sub { font-size: var(--gf-font-size-2xs); font-weight: var(--gf-font-weight-medium); diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte index 36ae383..7170fc5 100644 --- a/frontend/apps/rd-console/src/screens/HomeHub.svelte +++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte @@ -2,8 +2,9 @@ /** * HomeHub — the RD console's app-level landing (#118). * - * The two-level IA's root: three big, field-readable cards — **Pilots**, **Events**, **Timers** - * — each navigating to its own page. The picker is no longer the landing (it's the Events page), + * The two-level IA's root: big, field-readable cards — **Events** (the prominent, full-width + * primary action on top) above a smaller row of **Pilots**, **Classes**, **Timers** — each + * navigating to its own page. The picker is no longer the landing (it's the Events page), * and app-level timer management is no longer a modal (it's the Timers page). Each card shows a * quick at-a-glance summary read open (no token) on mount: Pilots count (`GET /pilots`), Events * count (`GET /events`), and timer count + how many are connected (`GET /timers`). A failed read @@ -89,115 +90,122 @@ />
      - GridFPV + GridFPV Race Director Console
      +
      - +
      + +
      - +
      + - + - + +
    @@ -240,6 +248,9 @@ font-size: var(--gf-font-size-2xl); letter-spacing: var(--gf-tracking-tight); } + .brand-text .name .brand-fpv { + color: var(--gf-brand-500); + } .brand-text .kicker { font-size: var(--gf-font-size-sm); color: var(--gf-text-muted); @@ -247,11 +258,28 @@ letter-spacing: var(--gf-tracking-caps); } + /* Events on top (full width), the three config cards beneath. `.cards` stacks the two rows; flip + the order here (e.g. `flex-direction: column-reverse`) to move Events to the bottom later. */ .cards { + display: flex; + flex-direction: column; + gap: var(--gf-space-5); + } + .events-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + grid-template-columns: 1fr; + } + .config-row { + display: grid; + grid-template-columns: repeat(3, 1fr); gap: var(--gf-space-5); } + @media (max-width: 44rem) { + /* Narrow widths: the three config cards wrap into a flexible grid. */ + .config-row { + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + } + } .hub-card { display: block; border: none; @@ -337,4 +365,29 @@ color: var(--gf-accent); transform: translateX(2px); } + + /* The prominent, full-width Events card: a taller body, a larger icon + title, and an accent + hairline so it reads as the primary action. */ + .hub-card.feature .card-body { + min-height: 13rem; + padding: var(--gf-space-5); + gap: var(--gf-space-4); + } + .hub-card.feature :global(.gf-card) { + border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); + background: linear-gradient(135deg, var(--gf-accent-soft) 0%, transparent 60%); + } + .hub-card.feature .card-icon { + width: 3.25rem; + height: 3.25rem; + } + .hub-card.feature .card-title { + font-size: var(--gf-font-size-3xl); + } + .hub-card.feature .card-summary .count { + font-size: var(--gf-font-size-3xl); + } + .hub-card.feature .card-go { + font-size: var(--gf-font-size-2xl); + } From bbcd97f2e8b27633e76523fa574c6148ab7c2988 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 02:37:44 +0000 Subject: [PATCH 099/362] docs: refresh design docs for the registries + race-running redesign Document the built app-level registries (pilot directory #74, class registry with 9 locked built-ins, timer registry #73 with failover) and the home-hub console IA, and the planned race-running redesign: event workspace as editable stage-pages (no Setup tab, wizard last), roster = per-class membership = presence, event-level class-tagged dynamic rounds with the quali->bracket top-N carry (#84), and timer-defined channels with dynamic node tuning (#117 folded into timers). Record the v0.4 slice plan (0 log-tags -> 7 wizard) and deferred items in the roadmap. Resolved open decisions: pilot-identity (directory), track scope (one per event), class semantics/identity (label + fixed built-in ids), channel model/ownership (timer-defined), per-class season identity. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/architecture.html | 28 +++++- docs/clients.html | 30 ++++++ docs/pipeline-mains.html | 47 ++++++++-- docs/pipeline-season.html | 8 +- docs/pipeline-setup.html | 186 +++++++++++++++++++++++++++----------- docs/race-engine.html | 24 ++++- docs/roadmap.html | 63 +++++++++---- docs/timer-adapters.html | 30 +++++- 8 files changed, 324 insertions(+), 92 deletions(-) diff --git a/docs/architecture.html b/docs/architecture.html index 2411ebb..c7a68c2 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -228,18 +228,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.
    @@ -319,6 +329,14 @@

    Realization 1 — Local (desktop Director, now)

    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)

    diff --git a/docs/clients.html b/docs/clients.html index 948516f..927e6dc 100644 --- a/docs/clients.html +++ b/docs/clients.html @@ -96,6 +96,36 @@

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

    It is information-dense and keyboard-friendly: a working tool for someone running a live event, not a viewer.

    +
    +

    + 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 diff --git a/docs/pipeline-mains.html b/docs/pipeline-mains.html index 74fe428..355a203 100644 --- a/docs/pipeline-mains.html +++ b/docs/pipeline-mains.html @@ -38,12 +38,31 @@

    Role

    Capabilities

    +
    +

    + 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(es) 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). Heat-filling has two v1 modes: manual and + format-generated (the six generators). ZippyQ-style rolling fills and PWA + self-register are 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, @@ -106,10 +125,18 @@

      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 + (Race Engine §5; Timer Adapters §5).
    • +
    • Sim has no channel step (the sim timer declares no channel capability).

    Sim vs IRL

    @@ -126,7 +153,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 +179,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-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..4a7cfe9 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,65 @@

    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 (planned, built last). Make the common path + effortless (a table stake against the incumbents) while leaving every choice editable + afterward. In the redesign the wizard is a thin guided first pass over the event + workspace's stage-pages (Classes → Roster → Rounds+Heats → …), not a separate setup + screen — so it is built last (v0.4 Slice 7), after the pages it walks.
  • -

    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 +129,39 @@

      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, not re-declared per event.
    • +
    • The timer's node/slot count caps the field per heat — you can assign at + most as many pilots to a heat as the timer has nodes.
    • +
    +

    + Per-heat channel assignment (engine first-fit + manual) and the dynamic + tuning of the timer's nodes at race time are detailed in + 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 +171,53 @@

    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
    +
    the selected timer defines the channels (a standard catalog + the adapter's fixed/flexible + capability + custom raw-MHz) and the node/slot count that caps a heat; channels live in the + timer config, not an event-level pool.

    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/race-engine.html b/docs/race-engine.html index faac08e..f0a7acf 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -190,16 +190,30 @@

      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 (planned). The engine's rounds-and-heats + are promoted to the event level and each round 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 existing + quali→bracket logic). The generator interface (§3) and advancement above are unchanged — the + redesign wires them per-event and tags the log entries with class/round. +

      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 diff --git a/docs/roadmap.html b/docs/roadmap.html index 3cca80b..74df1a0 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -153,26 +153,53 @@

      v0.4 — Direct a single race (RD console) — vertical slices

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

        -
      • Slice 0 — Design system foundation. Tokens, component library, and app - shell, built first so every later slice inherits a consistent UI.
      • -
      • Slice 1 — Events & workspace. The Event aggregate - itself: an always-present, non-persistent Practice event; a startup / event-picker - flow; you cannot act outside an event; and auth / connect to the served origin.
      • -
      • Slice 2 — Timer configuration. Per-event timing source — Sim, - RotorHazard URL, or future sources — chosen and validated inside the event.
      • -
      • Slice 3 — Pilot registration. Event-scoped pilots with auto-generated - IDs plus callsign / channel, and a working Registration UI.
      • -
      • Slice 4 — Heat building. Build heats from the registered roster — select - pilots, assign channels.
      • -
      • Slice 5 — Run a heat (live). Run a heat from the console with a - server-authoritative race clock.
      • -
      • Slice 6 — Marshaling & results. Void / insert / adjust laps and apply - penalties, with adjustments and penalties shown in the results UI.
      • -
      • Slice 7 — Multi-heat formats. Select a format and drive its progress — - the six generators already exist from v0.3.
      • +
      • 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.
      -

      Packaging follows alongside: a Tauri shell + single-binary build for Windows/Linux/macOS.

      +

      + Deferred out of v0.4: ZippyQ-style rolling fills and PWA self-register; channel + management as a standalone feature (#117 is folded into the Timer registry instead); 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.

      diff --git a/docs/timer-adapters.html b/docs/timer-adapters.html index bd36055..9b8a1b6 100644 --- a/docs/timer-adapters.html +++ b/docs/timer-adapters.html @@ -202,14 +202,32 @@

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

      +

      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

      @@ -305,11 +323,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.
    19. -
    20. Resolved — Frequency / channel management ownership. +
    21. 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.
    22. + 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.
    23. Resolved — Velocidrone WebSocket message schema. Reconstructed and implemented from two independent open-source consumers (the rh-velocidrone plugin and VelocidroneWebSocketConsumer); a raw From 82ff302501e08c26fcf8f32f8bd02a3d4067d643 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 02:37:52 +0000 Subject: [PATCH 100/362] Promote ClassId + add RoundId into events; tag HeatScheduled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `ClassId` from `server::scope` into the `gridfpv_events` crate (re-exported from `scope.rs`, mirroring `PilotId`) and add a sibling `RoundId` newtype, so the log and the wire/scope layer share one canonical class/round id. Both are `#[serde(transparent)]` string newtypes; the `ClassId.ts` type is byte-identical (only its defining crate moved), `RoundId.ts` is new. Tag `Event::HeatScheduled` and `Command::ScheduleHeat` with three additive, default-absent fields — `class: Option`, `round: Option`, `frequencies: Vec<(CompetitorRef, u16)>` (raw MHz per pilot) — threaded through `command_to_event`. All are `skip_serializing_if`, so the free-text NewHeat path and pre-existing serialized logs read back as None/empty (legacy round-trip test). Zero behavior change. Regenerated TS bindings (Event/Command/ClassId/RoundId + barrel export). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ClassId.ts | 7 ++ bindings/Command.ts | 17 ++- bindings/Event.ts | 17 ++- bindings/RoundId.ts | 9 ++ crates/app/src/source.rs | 26 ++++- crates/app/tests/rh_connect_live.rs | 6 + crates/engine/src/heat.rs | 6 + crates/engine/tests/common/mod.rs | 3 + crates/events/src/lib.rs | 142 ++++++++++++++++++++++- crates/server/src/app.rs | 9 ++ crates/server/src/control.rs | 38 +++++- crates/server/src/control_handler.rs | 66 ++++++++++- crates/server/src/events.rs | 6 + crates/server/src/live_state.rs | 8 +- crates/server/src/scope.rs | 11 +- crates/server/tests/control.rs | 21 ++++ crates/server/tests/full_event_live.rs | 3 + crates/server/tests/ws_stream.rs | 3 + frontend/packages/types/src/generated.ts | 1 + 19 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 bindings/RoundId.ts diff --git a/bindings/ClassId.ts b/bindings/ClassId.ts index d4a4685..37b647d 100644 --- a/bindings/ClassId.ts +++ b/bindings/ClassId.ts @@ -3,5 +3,12 @@ /** * 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/Command.ts b/bindings/Command.ts index 1fc4df9..a0dc151 100644 --- a/bindings/Command.ts +++ b/bindings/Command.ts @@ -1,10 +1,12 @@ // 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 { LogRef } from "./LogRef"; import type { Penalty } from "./Penalty"; import type { PilotId } from "./PilotId"; +import type { RoundId } from "./RoundId"; import type { SourceTime } from "./SourceTime"; /** @@ -73,7 +75,20 @@ heat: HeatId, /** * The competitors in the heat, in lineup order. */ -lineup: Array, } } | { "Register": { +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]>, } } | { "Register": { /** * The timing source the competitor belongs to. */ diff --git a/bindings/Event.ts b/bindings/Event.ts index 78bdaff..17f1135 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -1,5 +1,6 @@ // 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"; @@ -7,6 +8,7 @@ import type { LogRef } from "./LogRef"; import type { Pass } from "./Pass"; import type { Penalty } from "./Penalty"; import type { PilotId } from "./PilotId"; +import type { RoundId } from "./RoundId"; import type { SessionId } from "./SessionId"; import type { SourceTime } from "./SourceTime"; @@ -22,4 +24,17 @@ 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, } } | { "CompetitorRegistered": { adapter: AdapterId, competitor: CompetitorRef, pilot: PilotId, } } | { "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 } | { "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]>, } } | { "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, } }; 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/crates/app/src/source.rs b/crates/app/src/source.rs index 326b46c..1ad1b2c 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -810,7 +810,10 @@ 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 let Event::HeatScheduled { + heat: h, lineup: l, .. + } = s.event + { if &h == heat { lineup = Some(l); } @@ -946,6 +949,9 @@ mod tests { Event::HeatScheduled { heat: heat.clone(), lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1006,6 +1012,9 @@ mod tests { Event::HeatScheduled { heat: heat.clone(), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1065,6 +1074,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-1".into()), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1091,6 +1103,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-2".into()), lineup: vec![CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1139,6 +1154,9 @@ mod tests { Event::HeatScheduled { heat: heat.clone(), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1182,6 +1200,9 @@ mod tests { Event::HeatScheduled { heat: heat.clone(), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1246,6 +1267,9 @@ mod tests { Event::HeatScheduled { heat: heat.clone(), lineup, + class: None, + round: None, + frequencies: vec![], }, None, ) diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 3db6e66..d4bbfae 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -169,6 +169,9 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu Event::HeatScheduled { heat: heat.clone(), lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -354,6 +357,9 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { Event::HeatScheduled { heat: heat.clone(), lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], }, None, ) diff --git a/crates/engine/src/heat.rs b/crates/engine/src/heat.rs index 4c7706e..6ba9663 100644 --- a/crates/engine/src/heat.rs +++ b/crates/engine/src/heat.rs @@ -428,6 +428,9 @@ mod tests { CompetitorRef("node-0".into()), CompetitorRef("node-1".into()), ], + class: None, + round: None, + frequencies: vec![], } } @@ -494,6 +497,9 @@ mod tests { Event::HeatScheduled { heat: other.clone(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }, changed(HeatTransition::Staged), Event::HeatStateChanged { diff --git a/crates/engine/tests/common/mod.rs b/crates/engine/tests/common/mod.rs index faf4804..d7b7599 100644 --- a/crates/engine/tests/common/mod.rs +++ b/crates/engine/tests/common/mod.rs @@ -124,6 +124,9 @@ 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![], }]; // The heat's current FSM state, validated by `apply` and advanced by `next_state`. let mut state = HeatState::Scheduled; diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 4c1f343..75c1485 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -171,6 +171,29 @@ 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. @@ -275,12 +298,30 @@ pub enum Event { // --- race-engine events (#28) --- /// 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`, and `frequencies` 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). 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)>, }, /// A heat-loop state transition appended by the engine (race-engine.html §2). HeatStateChanged { @@ -401,6 +442,22 @@ mod tests { CompetitorRef("node-0".into()), CompetitorRef("node-1".into()), ], + class: None, + round: None, + frequencies: vec![], + }, + 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), + ], }, Event::HeatStateChanged { heat: HeatId("q-1".into()), @@ -481,4 +538,83 @@ mod tests { // 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 (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![], + }; + 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}" + ); + 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), + ], + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("class") && json.contains("round") && json.contains("frequencies")); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + + #[test] + fn legacy_heat_scheduled_reads_back_with_defaults() { + // A pre-existing serialized `HeatScheduled` (before the class/round/frequencies + // 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![], + } + ); + } } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 3762c1d..da1a9f2 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1173,6 +1173,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-1".into()), lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, Event::HeatStateChanged { heat: HeatId("q-1".into()), @@ -1425,6 +1428,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-1".into()), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, Event::HeatStateChanged { heat: HeatId("q-1".into()), @@ -1435,6 +1441,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-2".into()), lineup: vec![CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, Event::HeatStateChanged { heat: HeatId("q-2".into()), diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs index b90fb83..f737a66 100644 --- a/crates/server/src/control.rs +++ b/crates/server/src/control.rs @@ -21,7 +21,9 @@ //! > event/class addressing on commands (protocol.html §9.6) are refined alongside the //! > control endpoints (#45) and the doc-reconciliation pass. -use gridfpv_events::{AdapterId, CompetitorRef, HeatId, LogRef, Penalty, SourceTime}; +use gridfpv_events::{ + AdapterId, ClassId, CompetitorRef, HeatId, LogRef, Penalty, RoundId, SourceTime, +}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -98,13 +100,27 @@ pub enum Command { }, // --- Scheduling --- - /// Create a heat with its lineup (`Event::HeatScheduled`). Seat/frequency - /// assignment is the scheduler's concern; this commits who is in the heat. + /// Create a heat with its lineup (`Event::HeatScheduled`). Additively carries the + /// class/round the heat runs in and the per-pilot frequency assignment; all three + /// 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)>, }, // --- Registration --- @@ -250,6 +266,22 @@ mod tests { CompetitorRef("node-0".into()), CompetitorRef("node-1".into()), ], + class: None, + round: None, + frequencies: vec![], + }, + 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), + ], }, Command::Register { adapter: AdapterId("rh".into()), diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 368148a..a0804f4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -258,8 +258,22 @@ fn command_to_event(state: &AppState, command: Command) -> Result heat_transition(state, heat, HeatCommand::Restart), Command::Discard { heat } => heat_transition(state, heat, HeatCommand::Discard), - // --- Scheduling: creates the heat, so no prior-state check. --- - Command::ScheduleHeat { heat, lineup } => Ok(Event::HeatScheduled { heat, lineup }), + // --- Scheduling: creates the heat, so no prior-state check. The class/round/ + // frequency tags are carried straight through (default-absent for the + // free-text path). --- + Command::ScheduleHeat { + heat, + lineup, + class, + round, + frequencies, + } => Ok(Event::HeatScheduled { + heat, + lineup, + class, + round, + frequencies, + }), // --- Registration: bind a source competitor to a pilot (no prior-state check). --- Command::Register { @@ -389,6 +403,9 @@ mod tests { Event::HeatScheduled { heat: heat(), lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -475,7 +492,8 @@ mod tests { assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope); } - /// `ScheduleHeat` creates the heat with its lineup. + /// `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()); @@ -485,13 +503,50 @@ mod tests { Command::ScheduleHeat { heat: heat(), lineup: lineup.clone(), + class: None, + round: None, + frequencies: vec![], }, ); assert!(ack.ok); let (events, _) = state.read().unwrap(); assert!(events.iter().any(|e| matches!( e, - Event::HeatScheduled { heat: h, lineup: l } if *h == heat() && *l == lineup + Event::HeatScheduled { heat: h, lineup: l, class: None, round: None, frequencies } + 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(), + }, + ); + 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 ))); } @@ -528,6 +583,9 @@ mod tests { Event::HeatScheduled { heat: heat(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }, None, ) diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index e0b8236..5dbcdee 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -984,6 +984,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-1".into()), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) @@ -1098,6 +1101,9 @@ mod tests { Event::HeatScheduled { heat: HeatId("q-1".into()), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, None, ) diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 7c2ca0e..9857eaa 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -221,7 +221,10 @@ fn current_heat(events: &[Event]) -> Option { 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 let Event::HeatScheduled { + heat: h, lineup: l, .. + } = event + { if h == heat { lineup = l.clone(); } @@ -280,6 +283,9 @@ mod tests { Event::HeatScheduled { heat: HeatId(id.into()), lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(), + class: None, + round: None, + frequencies: vec![], } } diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs index 539eda1..3cb7197 100644 --- a/crates/server/src/scope.rs +++ b/crates/server/src/scope.rs @@ -41,10 +41,13 @@ pub struct EventId(pub String); /// Identifies a **class** within an event (protocol.html §4 "Class scope") — one /// class's phases, schedule, and standings, which may run in parallel with others. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] -#[serde(transparent)] -#[ts(export, export_to = "bindings/")] -pub struct ClassId(pub String); +/// +/// 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 diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index 3d429d5..0556efe 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -185,6 +185,9 @@ async fn post_command_drives_heat_loop_and_reaches_stream() { &Command::ScheduleHeat { heat: heat(), lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], }, &rd, ) @@ -223,6 +226,9 @@ async fn control_ws_acks_each_command_and_rejects_illegal() { &Command::ScheduleHeat { heat: heat(), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, ) .await; @@ -286,6 +292,9 @@ async fn control_ws_survives_a_malformed_frame() { &Command::ScheduleHeat { heat: heat(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }, ) .await; @@ -302,6 +311,9 @@ async fn control_post_requires_a_valid_rd_token() { let cmd = Command::ScheduleHeat { heat: heat(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }; // No token → 401. @@ -358,6 +370,9 @@ async fn control_ws_upgrade_requires_a_valid_rd_token() { &Command::ScheduleHeat { heat: heat(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }, ) .await; @@ -378,6 +393,9 @@ async fn reads_are_open_and_a_join_token_authenticates_reads() { &Command::ScheduleHeat { heat: heat(), lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], }, &rd, ) @@ -419,6 +437,9 @@ async fn out_of_band_contract_version_is_told_to_refresh() { &Command::ScheduleHeat { heat: heat(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], }, &rd, ) diff --git a/crates/server/tests/full_event_live.rs b/crates/server/tests/full_event_live.rs index 2e5ba9c..f7d82b2 100644 --- a/crates/server/tests/full_event_live.rs +++ b/crates/server/tests/full_event_live.rs @@ -322,6 +322,9 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { &Command::ScheduleHeat { heat: heat.clone(), lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], }, &rd, ) diff --git a/crates/server/tests/ws_stream.rs b/crates/server/tests/ws_stream.rs index 68e8030..e9b9c59 100644 --- a/crates/server/tests/ws_stream.rs +++ b/crates/server/tests/ws_stream.rs @@ -90,6 +90,9 @@ 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![], } } diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index b265461..b1577d9 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -72,6 +72,7 @@ export type * from '@bindings/ProjectionBody'; export type * from '@bindings/ProjectionKind'; export type * from '@bindings/ProtocolError'; export type * from '@bindings/RankEntry'; +export type * from '@bindings/RoundId'; export type * from '@bindings/Scope'; export type * from '@bindings/ServerHello'; export type * from '@bindings/SessionId'; From a7d4e51b8e4abda2ee3240954b65f9ca2b63a141 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 02:56:03 +0000 Subject: [PATCH 101/362] Race Slice 1a: per-class membership model + API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `EventMeta.classes_membership: Vec` (additive, omit-when-empty) recording which roster pilots race each selected class — the finer join of "who is present" (roster) and "which classes run" (classes). New `ClassMembership { class, pilots }` and `SetClassMembershipRequest` types derive serde + ts-rs. `EventRegistry::set_class_membership(event, class, pilot_ids)` replaces one class's pilot list wholesale (empty list clears the entry, last-write-wins), written through `persist_meta_change` (#115) so it round-trips across a Director restart. Route `PUT /events/{id}/classes/{class}/membership` (RD-gated) validates the event, the class (directory), and each pilot (directory), else a typed 404 — mirroring set_roster / set_classes. Frontend: regenerate bindings, add the two types to @gridfpv/types, add a `setClassMembership` protocol-client call, and a membership contract case. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ClassMembership.ts | 22 +++ bindings/EventMeta.ts | 20 +- bindings/SetClassMembershipRequest.ts | 14 ++ crates/server/src/app.rs | 44 ++++- crates/server/src/events.rs | 182 ++++++++++++++++++ frontend/contract/events.contract.ts | 87 +++++++++ .../packages/protocol-client/src/client.ts | 37 ++++ .../packages/protocol-client/src/index.ts | 1 + frontend/packages/types/src/generated.ts | 2 + 9 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 bindings/ClassMembership.ts create mode 100644 bindings/SetClassMembershipRequest.ts diff --git a/bindings/ClassMembership.ts b/bindings/ClassMembership.ts new file mode 100644 index 0000000..e66d705 --- /dev/null +++ b/bindings/ClassMembership.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 { ClassId } from "./ClassId"; +import type { PilotId } from "./PilotId"; + +/** + * 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 is a directory pilot that is + * also on the event's [`roster`](EventMeta::roster). + */ +pilots: Array, }; diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts index 21e1236..0e3418e 100644 --- a/bindings/EventMeta.ts +++ b/bindings/EventMeta.ts @@ -1,5 +1,6 @@ // 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 { TimerId } from "./TimerId"; @@ -95,4 +96,21 @@ roster: Array, * 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, }; +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, }; diff --git a/bindings/SetClassMembershipRequest.ts b/bindings/SetClassMembershipRequest.ts new file mode 100644 index 0000000..5051d4b --- /dev/null +++ b/bindings/SetClassMembershipRequest.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 { PilotId } from "./PilotId"; + +/** + * 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 must name a known pilot. + */ +pilot_ids: Array, }; diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index da1a9f2..c89bd73 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -92,7 +92,7 @@ use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; use crate::events::{ ActiveEvent, CreateEventRequest, EventMeta, EventRegistry, SetActiveEventRequest, - SetEventClassesRequest, SetEventRosterRequest, + SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, }; use crate::live_state::live_state; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; @@ -315,6 +315,13 @@ pub fn router(registry: EventRegistry) -> Router { // 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 **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)) @@ -799,6 +806,41 @@ async fn set_event_classes( 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 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_class_membership(&event_id, class_id, body.pilot_ids) + .map_err(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; + Ok(Json(meta)) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 5dbcdee..35d86a1 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -149,6 +149,38 @@ pub struct EventMeta { /// 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, +} + +/// 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 is a directory pilot that is + /// also on the event's [`roster`](EventMeta::roster). + pub pilots: Vec, } impl EventMeta { @@ -212,6 +244,17 @@ pub struct SetEventClassesRequest { 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 must name a known pilot. + pub pilot_ids: 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 @@ -347,6 +390,7 @@ impl EventRegistry { primary_timer: None, roster: Vec::new(), classes: Vec::new(), + classes_membership: Vec::new(), }, state: practice_state, }, @@ -549,6 +593,43 @@ impl EventRegistry { Ok(meta) } + /// Set the **per-class membership** for one class (race redesign Slice 1a), returning the + /// event's updated [`EventMeta`]. + /// + /// Replaces *that class's* pilot list wholesale with `pilot_ids` (other classes' memberships + /// are untouched). An empty `pilot_ids` 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 and each id names a directory pilot (the class/pilot directories 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, + pilot_ids: Vec, + ) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError(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 !pilot_ids.is_empty() { + event.meta.classes_membership.push(ClassMembership { + class, + pilots: pilot_ids, + }); + } + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + /// 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 @@ -691,6 +772,7 @@ impl EventRegistry { primary_timer: None, roster: Vec::new(), classes: Vec::new(), + classes_membership: 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) @@ -1256,6 +1338,106 @@ mod tests { 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(), 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, + 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(), 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, 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(), 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, 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 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()), + 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, + 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())); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index bddb9b7..d336188 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -895,3 +895,90 @@ describe('seam 12: application-level classes + per-event selection (#84)', () => expect((await setEventClasses(event.id, [cls.id])).status).toBe(401); }); }); + +/** + * Race redesign Slice 1a: **per-class membership** — given the event's present pilots (roster) and + * its selected classes, *which roster pilots race which class*. The membership is recorded on the + * event's `EventMeta.classes_membership` (additive, omitted from the wire when empty). + * + * guards: + * - a new event's `EventMeta.classes_membership` is absent (additive `#[serde(default)]`, + * omit-when-empty). + * - `PUT /events/{id}/classes/{classId}/membership` is **RD-gated** (no token → 401), validates the + * class names a known directory class and each pilot id names a directory pilot (unknown → 404 + * `UnknownScope`), and replaces that class's pilot list wholesale. + * - an empty `pilot_ids` clears the class's membership entry. + */ +describe('race Slice 1a: per-class membership', () => { + /** `PUT /events/{id}/classes/{classId}/membership` with `{ pilot_ids }` + optional token. */ + async function setMembership( + eventId: string, + classId: string, + pilotIds: string[], + token?: string + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch( + `${eventRoot(director.baseUrl, eventId)}/classes/${classId}/membership`, + { method: 'PUT', headers, body: JSON.stringify({ pilot_ids: pilotIds }) } + ); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + } + + /** Create a pilot, returning its id. */ + async function makePilot(callsign: string): Promise { + const res = await fetch(`${director.baseUrl}/pilots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ callsign }) + }); + return ((await res.json()) as Pilot).id; + } + + it('a new event has no class membership (additive, omit-when-empty)', async () => { + const event = (await createEvent('Membership Default', TOKEN)).body as EventMeta; + expect(event.classes_membership).toBeUndefined(); + }); + + it('PUT membership validates the class + pilots and replaces a class list wholesale', async () => { + const event = (await createEvent('Membership Event', TOKEN)).body as EventMeta; + const a = await makePilot('Member A'); + const b = await makePilot('Member B'); + + // Set the Open built-in class's membership. + const ok = await setMembership(event.id, 'mgp-open', [a, b], TOKEN); + expect(ok.status).toBe(200); + const meta = ok.body as EventMeta; + const entry = (meta.classes_membership ?? []).find((m) => m.class === 'mgp-open'); + expect(entry?.pilots).toEqual([a, b]); + + // Replacing that class's list is wholesale. + const replaced = (await setMembership(event.id, 'mgp-open', [a], TOKEN)).body as EventMeta; + const replacedEntry = (replaced.classes_membership ?? []).find((m) => m.class === 'mgp-open'); + expect(replacedEntry?.pilots).toEqual([a]); + + // An empty list clears the class's entry. + const cleared = (await setMembership(event.id, 'mgp-open', [], TOKEN)).body as EventMeta; + expect((cleared.classes_membership ?? []).some((m) => m.class === 'mgp-open')).toBe(false); + + // An UNKNOWN class id → 404 UnknownScope. + const badClass = await setMembership(event.id, 'no-such-class', [a], TOKEN); + expect(badClass.status).toBe(404); + expect((badClass.body as { code?: string }).code).toBe('UnknownScope'); + + // An UNKNOWN pilot id → 404 UnknownScope. + const badPilot = await setMembership(event.id, 'mgp-open', ['no-such-pilot'], TOKEN); + expect(badPilot.status).toBe(404); + expect((badPilot.body as { code?: string }).code).toBe('UnknownScope'); + + // RD-gated: no token → 401. + expect((await setMembership(event.id, 'mgp-open', [a])).status).toBe(401); + }); +}); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 20bc358..14c9a1a 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -753,6 +753,43 @@ export async function setEventClasses( return (await resp.json()) as EventMeta; } +/** + * Set which roster pilots race a single class (`PUT /events/{id}/classes/{classId}/membership`) — + * race redesign Slice 1a. Replaces *that class's* pilot list wholesale (an empty list clears it); + * other classes' memberships are untouched. RD-gated; the server validates the event exists, the + * class names a known directory class, and **each** pilot id names a known directory pilot (else + * **404**). Resolves to the updated event {@link EventMeta}, or rejects on a non-2xx / transport + * failure. + */ +export async function setClassMembership( + baseUrl: string, + eventId: EventId, + classId: ClassId, + pilotIds: PilotId[], + token?: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchImpl( + `${trimSlash(baseUrl)}${eventRoot(eventId)}/classes/${encodeURIComponent(classId)}/membership`, + { + method: 'PUT', + headers, + body: JSON.stringify({ pilot_ids: pilotIds }) + } + ); + if (!resp.ok) + throw new Error( + `PUT /events/${eventId}/classes/${classId}/membership failed: HTTP ${resp.status}` + ); + return (await resp.json()) as EventMeta; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index b4de7db..9de8ddb 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -36,6 +36,7 @@ export { updateClass, deleteClass, setEventClasses, + setClassMembership, PRACTICE_EVENT_ID } from './client.js'; export type { diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index b1577d9..7d7772a 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -31,6 +31,7 @@ export type * from '@bindings/Change'; export type * from '@bindings/ChangeEnvelope'; export type * from '@bindings/Class'; export type * from '@bindings/ClassId'; +export type * from '@bindings/ClassMembership'; export type * from '@bindings/ClassSource'; export type * from '@bindings/Command'; export type * from '@bindings/CommandAck'; @@ -77,6 +78,7 @@ export type * from '@bindings/Scope'; export type * from '@bindings/ServerHello'; export type * from '@bindings/SessionId'; export type * from '@bindings/SetActiveEventRequest'; +export type * from '@bindings/SetClassMembershipRequest'; export type * from '@bindings/SetEventClassesRequest'; export type * from '@bindings/SetEventRosterRequest'; export type * from '@bindings/SetEventTimersRequest'; From 2a86d0cc777f203e95b2800f9022b14d896c9700 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 02:56:10 +0000 Subject: [PATCH 102/362] Race Slice 1a: sim auto-presence reconciler off CompetitorSeen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-event reconciler (mirroring spawn_registry_bridge / run_bridge) that tails each event's log for `Event::CompetitorSeen` (emitted per sim player by the Velocidrone adapter). For each seen competitor not yet bound (folded via `registrations()`), if a directory pilot's callsign matches the competitor name (trimmed, case-insensitive), it (a) adds that pilot to the event roster — presence = roster membership — and (b) appends the `Event::CompetitorRegistered` binding the RD's `Command::Register` would, so the sim player's laps attribute to the pilot. Idempotent: roster add is set-membership; a binding is only appended for a competitor with no existing registration, so re-seeing the same player is a no-op. Unmatched / unrostered-no-match seen players are a no-op (the RD handles them in Slice 1b). Wired into the Director via `spawn_presence_reconciler`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/main.rs | 9 +- crates/app/src/source.rs | 302 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index ed1c30b..9ddd844 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -15,7 +15,9 @@ #![forbid(unsafe_code)] use gridfpv_app::director::{AssetStatus, Config, asset_status, build_app}; -use gridfpv_app::source::{SIM_ADAPTER, SourceConfig, spawn_registry_bridge}; +use gridfpv_app::source::{ + SIM_ADAPTER, SourceConfig, spawn_presence_reconciler, spawn_registry_bridge, +}; use gridfpv_app::{SyntheticPilot, append_and_project, render_lap_list, synthetic_session}; use gridfpv_events::AdapterId; use gridfpv_server::events::EventRegistry; @@ -65,6 +67,11 @@ async fn serve() -> Result<(), Box> { let _bridge = spawn_registry_bridge(registry.clone(), source, AdapterId(SIM_ADAPTER.to_string())); + // Spawn the **sim auto-presence reconciler** (race redesign Slice 1a): per event it tails the + // log for the sim adapter's `CompetitorSeen` and auto-adds + binds any seen player whose name + // matches a directory pilot's callsign — so a sim race needs no manual roster/registration. + let _presence = spawn_presence_reconciler(registry.clone()); + let app = build_app(registry, &config.assets); let listener = tokio::net::TcpListener::bind(config.addr).await?; diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 1ad1b2c..1b20f8e 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -51,8 +51,10 @@ 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; @@ -569,6 +571,173 @@ pub(crate) async fn run_bridge( } } +// --- 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 { + 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; + 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, + }; + for (offset, event) in new_events { + cursor = offset + 1; + if let Event::CompetitorSeen { + adapter, + competitor, + } = event + { + 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). /// @@ -1227,6 +1396,139 @@ mod tests { 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(); + } + #[test] fn source_config_defaults_to_sim_and_describes_itself() { // No env reliance: build a sim config directly and confirm the banner text. From 662b8a30b78695709980f6cde61d07e9faf09655 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 03:17:10 +0000 Subject: [PATCH 103/362] Race Slice 1b: Roster stage UI (per-class membership + presence + binding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the in-event Registration screen into the **Roster stage**, the single screen where the RD settles who races the event and binds the timing source. - **Rename the tab** `registration`→`roster` across `route.ts` (`WorkspaceTab`/`WORKSPACE_TABS`) and `App.svelte` (`SCREENS`, label "Roster"), keeping the hash-tab + nav behavior. Route/routing tests updated. - **EventRoster.svelte** becomes three layers, coarse→fine: - **Present pilots** = the event roster (the existing directory→roster selection via `setEventRoster`, embedded `PilotManager` for inline CRUD). Re-seeds the working set off the active-event stream so the sim auto-presence reconciler's players appear **live** without RD action. - **Per-class membership** — for each selected class (resolved to a name via the class directory), a grid placing roster pilots into it; saves via `setClassMembership(class, pilotIds)`, reflecting `classes_membership`. A pilot can be in several classes; nudges to pick classes first when none. - **Binding** — present pilots' bound/unbound status off the live heat registrations (`PilotProgress.pilot`), plus a manual `(adapter, competitor) → pilot` bind form issuing `Command::Register`. - Field-readable (large text, dark), consistent with the stage screens. - **Session**: add `setClassMembership` (reuses the protocol-client call) and a `registerCompetitor` bind helper over the control path; test support seam. Gates: build/check/lint/test (210)/contract (66) green; `cargo xtask ci` green. Unit tests cover membership placement + the bind action; `roster.spec.ts` e2e extended to place a pilot into a class (persists across reload) + a manual bind, full e2e suite (10) green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 17 +- frontend/apps/rd-console/src/lib/route.ts | 6 +- .../apps/rd-console/src/lib/session.svelte.ts | 42 ++ .../rd-console/src/screens/EventRoster.svelte | 492 ++++++++++++++++-- .../apps/rd-console/tests/EventRoster.test.ts | 144 ++++- frontend/apps/rd-console/tests/route.test.ts | 10 +- frontend/apps/rd-console/tests/support.ts | 9 +- frontend/e2e/roster.spec.ts | 145 +++++- frontend/e2e/routing.spec.ts | 26 +- 9 files changed, 812 insertions(+), 79 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 6bd5f0e..bfb299d 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -123,19 +123,12 @@ } }); - type ScreenId = - | 'setup' - | 'timers' - | 'registration' - | 'classes' - | 'live' - | 'marshaling' - | 'results'; + type ScreenId = 'setup' | 'timers' | 'roster' | 'classes' | 'live' | 'marshaling' | 'results'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ { id: 'setup', label: 'Setup', key: '1', icon: 'M4 6h16M4 12h16M4 18h10' }, { - id: 'registration', - label: 'Registration', + id: 'roster', + label: 'Roster', key: '2', icon: 'M16 11V7a4 4 0 1 0-8 0v4M5 11h14v9H5z' }, @@ -169,7 +162,7 @@ // The console is already inside an event (#72) — the live read client was scoped to it on // entry — so committing the wizard just advances to registration; there is no separate // event to re-scope to (the redundant event field was removed, #72 Slice 1b A1). - setTab('registration'); + setTab('roster'); } function leaveToPicker() { @@ -355,7 +348,7 @@ /> {:else if active === 'timers'} - {:else if active === 'registration'} + {:else if active === 'roster'} {:else if active === 'classes'} diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index 41bf78d..c0fd555 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -17,7 +17,7 @@ * - `#/events` → the Events page (the picker) * - `#/timers` → the Timers page * - `#/event/` → the in-event workspace on `` - * (tab ∈ setup | registration | classes | live | marshaling | results | timers) + * (tab ∈ setup | roster | classes | live | marshaling | results | timers) * * The active event itself is **app-wide server state** (the Director's active event, #90), so the * hash only restores *which tab* of the workspace — not *which* event. The workspace always shows @@ -31,7 +31,7 @@ export type AppPage = 'home' | 'events' | 'timers' | 'pilots' | 'classes'; /** The in-event workspace sidebar tabs. */ export type WorkspaceTab = | 'setup' - | 'registration' + | 'roster' | 'classes' | 'live' | 'marshaling' @@ -47,7 +47,7 @@ export type Route = { kind: 'page'; page: AppPage } | { kind: 'workspace'; tab: export const WORKSPACE_TABS: readonly WorkspaceTab[] = [ 'setup', - 'registration', + 'roster', 'classes', 'live', 'marshaling', diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index e161712..3b4c945 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -60,16 +60,19 @@ import { updateClass, deleteClass, setEventClasses, + setClassMembership, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; import { createControlClient } from './control.js'; import type { ControlClient } from './control.js'; import type { + AdapterId, Class, ClassId, Command, CommandAck, + CompetitorRef, CreateClassRequest, CreateEventRequest, CreatePilotRequest, @@ -239,6 +242,7 @@ export class Session { #updateClassImpl: typeof updateClass; #deleteClassImpl: typeof deleteClass; #setEventClassesImpl: typeof setEventClasses; + #setClassMembershipImpl: typeof setClassMembership; constructor(opts?: { connectImpl?: typeof connect; @@ -265,6 +269,7 @@ export class Session { updateClassImpl?: typeof updateClass; deleteClassImpl?: typeof deleteClass; setEventClassesImpl?: typeof setEventClasses; + setClassMembershipImpl?: typeof setClassMembership; baseUrl?: string; autoRestore?: boolean; }) { @@ -292,6 +297,7 @@ export class Session { this.#updateClassImpl = opts?.updateClassImpl ?? updateClass; this.#deleteClassImpl = opts?.deleteClassImpl ?? deleteClass; this.#setEventClassesImpl = opts?.setEventClassesImpl ?? setEventClasses; + this.#setClassMembershipImpl = opts?.setClassMembershipImpl ?? setClassMembership; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -581,6 +587,42 @@ export class Session { return updated; } + /** + * Set which roster pilots race a single **class** of the current event + * (`PUT /events/{id}/classes/{classId}/membership`) — race redesign Slice 1b. This is the finer + * per-class join layered on the roster (who is *present*) and the class selection (which + * categories *run*): given those, which present pilots race *this* class. Pass the full set of + * pilot ids for the class (replaces *that class's* membership wholesale; an empty list clears it; + * other classes are untouched). No-op (resolves `undefined`) when no event is selected. On success + * the updated {@link EventMeta} replaces {@link currentEvent} so the workspace's view of + * `classes_membership` stays in sync; returns it, `undefined` on a cancelled prompt, or throws. + */ + async setClassMembership(classId: ClassId, pilotIds: PilotId[]): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#setClassMembershipImpl(this.baseUrl, event.id, classId, pilotIds, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + + /** + * Bind a timing-source competitor to a pilot for lap attribution (`Command::Register`) — the + * IRL **binding** step of the Roster stage. A timing source (RotorHazard node, sim player) reports + * a source-local {@link CompetitorRef} that means nothing to GridFPV until the RD binds it to a + * directory {@link PilotId}; the projection then attributes that competitor's laps to the pilot. + * (The sim auto-binds its players, so this is the manual IRL path.) Sent through the control path + * (full-trust first → lazy token), so it returns the raw {@link CommandAck} like {@link send}. + */ + registerCompetitor( + adapter: AdapterId, + competitor: CompetitorRef, + pilot: PilotId + ): Promise { + return this.send({ Register: { adapter, competitor, pilot } }); + } + /** * Add one pilot to the **current event's** roster (`POST /events/{id}/roster/{pilotId}`) — issue * #74. Idempotent. No-op (resolves `undefined`) when no event is selected. On success the updated diff --git a/frontend/apps/rd-console/src/screens/EventRoster.svelte b/frontend/apps/rd-console/src/screens/EventRoster.svelte index 8947c0a..34ed1ee 100644 --- a/frontend/apps/rd-console/src/screens/EventRoster.svelte +++ b/frontend/apps/rd-console/src/screens/EventRoster.svelte @@ -1,35 +1,45 @@ -
      +
      + {#snippet actions()} @@ -113,7 +281,7 @@ (which only renders when the directory has pilots). -->

      {orderedSelection.length} of {pilots.length} - {pilots.length === 1 ? 'pilot' : 'pilots'} rostered for this event + {pilots.length === 1 ? 'pilot' : 'pilots'} present at this event

      - {orderedSelection.length} rostered + {orderedSelection.length} present
      {#if changed} @@ -150,14 +318,132 @@ {/snippet} + + + + {#if eventClasses.length === 0} +
      +

      This event has no classes selected yet.

      +

      Pick the classes it runs on the Classes tab first.

      +
      + {:else if rosterPilots.length === 0} +
      +

      No pilots are present yet.

      +

      Mark some pilots present above, then place them into classes.

      +
      + {:else} +
      + {#each eventClasses as classId (classId)} + {@const members = working(classId)} + {@const dirty = membershipChanged(classId)} +
      + + {classNameOf(classId)} + {members.size} {members.size === 1 ? 'pilot' : 'pilots'} + +
        + {#each rosterPilots as pilot (pilot.id)} +
      • + +
      • + {/each} +
      +
      + +
      +
      + {/each} +
      + {/if} +
      + + + + {#if rosterPilots.length === 0} +
      +

      No present pilots to bind yet.

      +
      + {:else} +
        + {#each rosterPilots as pilot (pilot.id)} + {@const ref = boundRefByPilot.get(pilot.id)} +
      • + {pilot.callsign} + {#if ref} + bound → {ref} + {:else} + unbound + {/if} +
      • + {/each} +
      + +
      +
      + + + + + + + + + +
      +
      + + {#if bindPilot !== ''} + + {bindAdapter || '—'} / {bindCompetitor || '…'} → {callsignOf(bindPilot)} + + {/if} +
      +
      + {/if} +
      diff --git a/frontend/apps/rd-console/tests/EventRoster.test.ts b/frontend/apps/rd-console/tests/EventRoster.test.ts index d6ab8fa..9881951 100644 --- a/frontend/apps/rd-console/tests/EventRoster.test.ts +++ b/frontend/apps/rd-console/tests/EventRoster.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/svelte'; import { fireEvent, waitFor } from '@testing-library/dom'; -import type { EventMeta, Pilot } from '@gridfpv/types'; +import type { Class, EventMeta, LiveRaceState, Pilot } from '@gridfpv/types'; import EventRoster from '../src/screens/EventRoster.svelte'; import { makeTestSession } from './support.js'; const ACE: Pilot = { id: 'p1', callsign: 'Ace', name: 'Alice', vtx_types: [], attributes: {} }; const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; +const OPEN: Class = { id: 'open', name: 'Open Class', source: 'Custom' }; + /** An event that already rosters Ace (so its checkbox seeds checked). */ const EVENT: EventMeta = { id: 'e1', @@ -19,7 +21,7 @@ const EVENT: EventMeta = { classes: [] }; -describe('EventRoster (in-event roster + inline CRUD)', () => { +describe('EventRoster — present pilots (in-event roster + inline CRUD)', () => { it('seeds the checkboxes from the event roster and shows the count', async () => { const listPilotsImpl = vi.fn(async () => [ACE, BEE]); const { session } = makeTestSession({ listPilotsImpl, event: EVENT }); @@ -30,9 +32,9 @@ describe('EventRoster (in-event roster + inline CRUD)', () => { expect(aceBox.checked).toBe(true); expect(beeBox.checked).toBe(false); - // The header count reflects 1 of 2 rostered. - expect(screen.getByText(/of 2 pilots rostered for this event/i)).toBeInTheDocument(); - expect(within(screen.getByText(/rostered for this event/i)).getByText('1')).toBeInTheDocument(); + // The header count reflects 1 of 2 present. + expect(screen.getByText(/of 2 pilots present at this event/i)).toBeInTheDocument(); + expect(within(screen.getByText(/present at this event/i)).getByText('1')).toBeInTheDocument(); // No change yet → Save disabled. expect( @@ -107,3 +109,135 @@ describe('EventRoster (in-event roster + inline CRUD)', () => { await screen.findByText(/No pilots in the directory yet/i); }); }); + +describe('EventRoster — per-class membership', () => { + // An event that runs the Open class and has Ace + Bee present. + const EV_CLASS: EventMeta = { ...EVENT, roster: ['p1', 'p2'], classes: ['open'] }; + + it('nudges to pick classes first when the event has none selected', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session } = makeTestSession({ listPilotsImpl, event: EVENT }); + render(EventRoster, { session }); + await screen.findByText(/no classes selected yet/i); + }); + + it('places a roster pilot into a class and saves via setClassMembership in roster order', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const listClassesImpl = vi.fn(async () => [OPEN]); + const setClassMembershipImpl = vi.fn(async () => ({ + ...EV_CLASS, + classes_membership: [{ class: 'open', pilots: ['p1'] }] + })); + const { session } = makeTestSession({ + listPilotsImpl, + listClassesImpl, + setClassMembershipImpl, + event: EV_CLASS + }); + render(EventRoster, { session }); + + // The class section resolves the id → name and lists the roster pilots as checkboxes. + const aceInOpen = (await screen.findByLabelText('Place Ace in Open Class')) as HTMLInputElement; + expect(aceInOpen.checked).toBe(false); + await fireEvent.click(aceInOpen); + + const save = screen.getByRole('button', { name: 'Save membership' }) as HTMLButtonElement; + expect(save.disabled).toBe(false); + await fireEvent.click(save); + + await waitFor(() => expect(setClassMembershipImpl).toHaveBeenCalledTimes(1)); + expect(setClassMembershipImpl).toHaveBeenCalledWith( + 'http://d.local', + 'e1', + 'open', + ['p1'], + 'tok' + ); + // currentEvent re-homes; the membership sticks (the box stays checked, Save goes disabled). + await waitFor(() => + expect((screen.getByLabelText('Place Ace in Open Class') as HTMLInputElement).checked).toBe( + true + ) + ); + await waitFor(() => + expect( + (screen.getByRole('button', { name: 'Save membership' }) as HTMLButtonElement).disabled + ).toBe(true) + ); + }); + + it('seeds the class checkboxes from the saved membership', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const listClassesImpl = vi.fn(async () => [OPEN]); + const { session } = makeTestSession({ + listPilotsImpl, + listClassesImpl, + event: { ...EV_CLASS, classes_membership: [{ class: 'open', pilots: ['p2'] }] } + }); + render(EventRoster, { session }); + + const beeInOpen = (await screen.findByLabelText('Place Bee in Open Class')) as HTMLInputElement; + expect(beeInOpen.checked).toBe(true); + expect((screen.getByLabelText('Place Ace in Open Class') as HTMLInputElement).checked).toBe( + false + ); + }); +}); + +describe('EventRoster — binding', () => { + const EV_PRESENT: EventMeta = { ...EVENT, roster: ['p1', 'p2'], classes: [] }; + + it('shows a present pilot as unbound, then bound from the live heat registrations', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + // Ace is bound to competitor "node-1" in the current heat; Bee is not. + const live: LiveRaceState = { + phase: 'Running', + progress: [{ competitor: 'node-1', pilot: 'p1', laps_completed: 0 }] + }; + const { session } = makeTestSession({ listPilotsImpl, live, event: EV_PRESENT }); + render(EventRoster, { session }); + + const bindList = await screen.findByRole('list', { name: 'Pilot bindings' }); + expect(within(bindList).getByText(/bound → node-1/i)).toBeInTheDocument(); + expect(within(bindList).getByText(/^unbound$/i)).toBeInTheDocument(); + }); + + it('binds a competitor to a pilot via Register through the control path', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session, sendSpy } = makeTestSession({ listPilotsImpl, event: EV_PRESENT }); + render(EventRoster, { session }); + + const adapter = (await screen.findByLabelText('Bind adapter')) as HTMLInputElement; + const competitor = screen.getByLabelText('Bind competitor') as HTMLInputElement; + const pilot = screen.getByLabelText('Bind pilot') as HTMLSelectElement; + + await fireEvent.input(adapter, { target: { value: 'rh-1' } }); + await fireEvent.input(competitor, { target: { value: 'node-3' } }); + await fireEvent.change(pilot, { target: { value: 'p2' } }); + + const bindBtn = screen.getByRole('button', { name: 'Bind' }) as HTMLButtonElement; + expect(bindBtn.disabled).toBe(false); + await fireEvent.click(bindBtn); + + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1)); + expect(sendSpy).toHaveBeenCalledWith({ + Register: { adapter: 'rh-1', competitor: 'node-3', pilot: 'p2' } + }); + }); + + it('keeps Bind disabled until adapter, competitor, and pilot are all set', async () => { + const listPilotsImpl = vi.fn(async () => [ACE, BEE]); + const { session } = makeTestSession({ listPilotsImpl, event: EV_PRESENT }); + render(EventRoster, { session }); + + // The adapter defaults to "sim", so only competitor + pilot are missing. + const bindBtn = (await screen.findByRole('button', { name: 'Bind' })) as HTMLButtonElement; + expect(bindBtn.disabled).toBe(true); + await fireEvent.input(screen.getByLabelText('Bind competitor'), { + target: { value: 'node-3' } + }); + expect(bindBtn.disabled).toBe(true); // still no pilot + await fireEvent.change(screen.getByLabelText('Bind pilot'), { target: { value: 'p1' } }); + expect(bindBtn.disabled).toBe(false); + }); +}); diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts index ef9c0ef..068249c 100644 --- a/frontend/apps/rd-console/tests/route.test.ts +++ b/frontend/apps/rd-console/tests/route.test.ts @@ -37,7 +37,7 @@ describe('parseHash', () => { it('is tolerant of a missing leading slash and a trailing slash and casing', () => { expect(parseHash('pilots')).toEqual({ kind: 'page', page: 'pilots' }); expect(parseHash('#/pilots/')).toEqual({ kind: 'page', page: 'pilots' }); - expect(parseHash('#/EVENT/Registration')).toEqual({ kind: 'workspace', tab: 'registration' }); + expect(parseHash('#/EVENT/Roster')).toEqual({ kind: 'workspace', tab: 'roster' }); }); it('falls back to the hub for an unknown hash', () => { @@ -58,7 +58,7 @@ describe('formatHash', () => { it('formats pages and workspace tabs', () => { expect(formatHash({ kind: 'page', page: 'pilots' })).toBe('#/pilots'); - expect(formatHash({ kind: 'workspace', tab: 'registration' })).toBe('#/event/registration'); + expect(formatHash({ kind: 'workspace', tab: 'roster' })).toBe('#/event/roster'); }); }); @@ -110,14 +110,14 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => { }); it('a workspace tab hash restores that tab when an event is active', () => { - expect(resolveInitialRoute('#/event/registration', true)).toEqual({ + expect(resolveInitialRoute('#/event/roster', true)).toEqual({ kind: 'workspace', - tab: 'registration' + tab: 'roster' }); }); it('a workspace hash with no active event reconciles to the Events page', () => { - expect(resolveInitialRoute('#/event/registration', false)).toEqual({ + expect(resolveInitialRoute('#/event/roster', false)).toEqual({ kind: 'page', page: 'events' }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index a9018eb..9ec2be8 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -25,7 +25,8 @@ import type { createClass, updateClass, deleteClass, - setEventClasses + setEventClasses, + setClassMembership } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -67,6 +68,8 @@ export interface TimerImpls { deleteClassImpl?: typeof deleteClass; /** The per-event class-selection write seam (issue #84) — backs the EventClasses tests. */ setEventClassesImpl?: typeof setEventClasses; + /** The per-class membership write seam (race redesign Slice 1b) — backs the EventRoster tests. */ + setClassMembershipImpl?: typeof setClassMembership; } export interface TestSession { @@ -132,7 +135,9 @@ export function makeTestSession( createClassImpl: opts?.createClassImpl, updateClassImpl: opts?.updateClassImpl, deleteClassImpl: opts?.deleteClassImpl, - setEventClassesImpl: opts?.setEventClassesImpl + setEventClassesImpl: opts?.setEventClassesImpl, + // Per-class membership write seam (race redesign Slice 1b): inert unless a test overrides it. + setClassMembershipImpl: opts?.setClassMembershipImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/e2e/roster.spec.ts b/frontend/e2e/roster.spec.ts index 7b2f247..f5d6e53 100644 --- a/frontend/e2e/roster.spec.ts +++ b/frontend/e2e/roster.spec.ts @@ -40,16 +40,16 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ await expect(liveNav).toBeVisible({ timeout: 15_000 }); } - // ── Open the Registration screen (the EventRoster) from the workspace sidebar ─────────────── + // ── Open the Roster stage (the EventRoster) from the workspace sidebar ─────────────── await page .getByRole('navigation', { name: 'Screens' }) - .getByRole('button', { name: 'Registration' }) + .getByRole('button', { name: 'Roster' }) .click(); - await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeVisible({ + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeVisible({ timeout: 15_000 }); // The roster count header is present. - await expect(page.getByText(/rostered for this event/i)).toBeVisible(); + await expect(page.getByText(/present at this event/i)).toBeVisible(); // ── Register a brand-new pilot inline — without leaving the event ──────────────────────────── await page.getByRole('button', { name: '+ Add pilot' }).click(); @@ -80,7 +80,7 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ await expect(liveNav).toBeVisible({ timeout: 15_000 }); await page .getByRole('navigation', { name: 'Screens' }) - .getByRole('button', { name: 'Registration' }) + .getByRole('button', { name: 'Roster' }) .click(); const rowAfter = page .getByRole('list', { name: 'Pilot directory' }) @@ -108,3 +108,138 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ }) ).toHaveCount(0, { timeout: 15_000 }); }); + +/** + * The Roster stage's **per-class membership** (race redesign Slice 1b) + a manual **bind**, end to + * end against a real Director. + * + * Enter an event (Practice), select the built-in **Open Class** onto it (so the Roster stage has a + * class to place into), then on the Roster stage: register a pilot inline, mark them present, and + * **place them into the Open Class** — the key proof, which we assert **persists across a reload** + * (the class checkbox seeds checked off `EventMeta.classes_membership`). Then exercise the manual + * binding form (`Command::Register`). Cleans up after itself (the worker's Director is shared): + * empties the membership, unticks the class, and removes the pilot. + */ +test('RD places a present pilot into a class (membership persists) and binds a competitor', async ({ + page +}) => { + const CS = `E2E-Member-${Date.now()}`; + const nav = page.getByRole('navigation', { name: 'Screens' }); + await page.goto('/'); + + // ── Get into an event (Practice). Same resume-tolerant dance as the spec above. ── + const liveNav = page.getByRole('button', { name: /Live control/ }); + const eventsCard = page.getByRole('button', { name: /Events/ }); + await expect(liveNav.or(eventsCard).first()).toBeVisible({ timeout: 15_000 }); + if (!(await liveNav.isVisible().catch(() => false))) { + await eventsCard.click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await picker.isVisible().catch(() => false)) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + } + + // ── Select the built-in "Open Class" onto the event (the Classes tab) so the Roster stage has a + // class to place pilots into. ── + await nav.getByRole('button', { name: 'Classes' }).click(); + await expect(page.getByRole('heading', { name: 'Classes for this event' })).toBeVisible({ + timeout: 15_000 + }); + const classRow = page + .getByRole('list', { name: 'Class directory' }) + .getByRole('listitem') + .filter({ hasText: 'Open Class' }); + const classBox = classRow.getByRole('checkbox', { name: 'Select Open Class' }); + if (!(await classBox.isChecked())) await classBox.check(); + await expect(classBox).toBeChecked(); + await page.getByRole('button', { name: 'Save classes' }).click(); + await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ + timeout: 15_000 + }); + + // ── Roster stage: register a pilot inline + mark present ────────────────────────────────────── + await nav.getByRole('button', { name: 'Roster' }).click(); + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeVisible({ + timeout: 15_000 + }); + await page.getByRole('button', { name: '+ Add pilot' }).click(); + const addForm = page.getByRole('form', { name: 'Add pilot' }); + await expect(addForm).toBeVisible(); + await addForm.getByLabel('Callsign').fill(CS); + await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + const dir = page.getByRole('list', { name: 'Pilot directory' }); + const pilotRow = dir.getByRole('listitem').filter({ hasText: CS }); + await expect(pilotRow).toBeVisible({ timeout: 15_000 }); + await pilotRow.getByRole('checkbox', { name: `Roster ${CS}` }).check(); + await page.getByRole('button', { name: 'Save roster' }).click(); + await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); + + // ── Place the present pilot into the Open Class and save that class's membership ─────────────── + const memberBox = page.getByRole('checkbox', { name: `Place ${CS} in Open Class` }); + await expect(memberBox).toBeVisible({ timeout: 15_000 }); + await expect(memberBox).not.toBeChecked(); + await memberBox.check(); + const saveMembership = page.getByRole('button', { name: 'Save membership' }); + await saveMembership.click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + await expect(saveMembership).toBeDisabled({ timeout: 15_000 }); + + // ── The membership persisted: a reload seeds the class checkbox checked off + // `EventMeta.classes_membership`. ── + await page.reload(); + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + await nav.getByRole('button', { name: 'Roster' }).click(); + const memberAfter = page.getByRole('checkbox', { name: `Place ${CS} in Open Class` }); + await expect(memberAfter).toBeChecked({ timeout: 15_000 }); + + // ── Manual bind: bind a source-local competitor to the present pilot via Command::Register ──── + await page.getByLabel('Bind competitor').fill('node-7'); + await page.getByLabel('Bind pilot').selectOption({ label: CS }); + await page.getByRole('button', { name: 'Bind', exact: true }).click(); + // An open Director accepts the Register tokenless — no token prompt, no error toast. + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // ── Clean up (shared Director): empty the membership, untick the class, remove the pilot ─────── + await memberAfter.uncheck(); + await page.getByRole('button', { name: 'Save membership' }).click(); + await expect(page.getByRole('button', { name: 'Save membership' })).toBeDisabled({ + timeout: 15_000 + }); + + await nav.getByRole('button', { name: 'Classes' }).click(); + const classRowAfter = page + .getByRole('list', { name: 'Class directory' }) + .getByRole('listitem') + .filter({ hasText: 'Open Class' }); + const classBoxAfter = classRowAfter.getByRole('checkbox', { name: 'Select Open Class' }); + if (await classBoxAfter.isChecked()) { + await classBoxAfter.uncheck(); + await page.getByRole('button', { name: 'Save classes' }).click(); + await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ + timeout: 15_000 + }); + } + + await nav.getByRole('button', { name: 'Roster' }).click(); + const cleanupRow = page + .getByRole('list', { name: 'Pilot directory' }) + .getByRole('listitem') + .filter({ hasText: CS }); + await cleanupRow.getByRole('button', { name: 'Remove' }).click(); + const confirm2 = page.getByRole('dialog').filter({ hasText: 'Remove pilot' }); + await expect(confirm2).toBeVisible(); + await confirm2.getByRole('button', { name: 'Remove' }).click(); + await expect( + page + .getByRole('list', { name: 'Pilot directory' }) + .getByRole('listitem') + .filter({ hasText: CS }) + ).toHaveCount(0, { timeout: 15_000 }); +}); diff --git a/frontend/e2e/routing.spec.ts b/frontend/e2e/routing.spec.ts index 075799c..213e699 100644 --- a/frontend/e2e/routing.spec.ts +++ b/frontend/e2e/routing.spec.ts @@ -4,7 +4,7 @@ * * The console's view used to live only in in-memory state, so a reload reset it (back to the active * event's workspace or the hub) — losing where you were. Now the view is reflected in - * `location.hash` (e.g. `#/pilots`, `#/event/registration`), so a refresh restores it, bookmarks + * `location.hash` (e.g. `#/pilots`, `#/event/roster`), so a refresh restores it, bookmarks * work, and the browser's back/forward navigate between views. * * Why a hash and not a path: `/pilots`, `/events`, `/timers` are already Director API routes — a @@ -85,37 +85,37 @@ test('inside an event, a refresh stays on the open tab; browser back returns to await expect(page.getByRole('button', { name: /Live control/ })).toBeVisible({ timeout: 15_000 }); await expect.poll(() => new URL(page.url()).hash).toBe('#/event/live'); - // Open the Registration tab from the sidebar → the hash becomes #/event/registration. + // Open the Roster tab from the sidebar → the hash becomes #/event/roster. await page .getByRole('navigation', { name: 'Screens' }) - .getByRole('button', { name: 'Registration' }) + .getByRole('button', { name: 'Roster' }) .click(); - await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeVisible({ + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeVisible({ timeout: 15_000 }); - await expect.poll(() => new URL(page.url()).hash).toBe('#/event/registration'); + await expect.poll(() => new URL(page.url()).hash).toBe('#/event/roster'); - // Reload — the key proof: we resume into the SAME event AND the SAME tab (Registration), not the + // Reload — the key proof: we resume into the SAME event AND the SAME tab (Roster), not the // workspace's default Live tab. The active event is server state (#90); the hash restores the tab. await page.reload(); - await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeVisible({ + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeVisible({ timeout: 15_000 }); - expect(new URL(page.url()).hash).toBe('#/event/registration'); + expect(new URL(page.url()).hash).toBe('#/event/roster'); // Browser BACK moves to the previous view (the Live tab) — driven by hashchange. await page.goBack(); await expect(page.getByRole('button', { name: /Live control/ })).toBeVisible({ timeout: 15_000 }); await expect.poll(() => new URL(page.url()).hash).toBe('#/event/live'); - // The Registration heading is gone — we really moved off that tab. - await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeHidden(); + // The Roster heading is gone — we really moved off that tab. + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeHidden(); - // Browser FORWARD returns to Registration. + // Browser FORWARD returns to Roster. await page.goForward(); - await expect(page.getByRole('heading', { name: 'Roster for this event' })).toBeVisible({ + await expect(page.getByRole('heading', { name: 'Present pilots' })).toBeVisible({ timeout: 15_000 }); - await expect.poll(() => new URL(page.url()).hash).toBe('#/event/registration'); + await expect.poll(() => new URL(page.url()).hash).toBe('#/event/roster'); }); test('on the hub with an active event, a refresh stays on the hub (no auto-resume into the event)', async ({ From 52103035ae2a4961899cc9ef1c949020c395aeca Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 03:32:02 +0000 Subject: [PATCH 104/362] =?UTF-8?q?Race=20Slice=202a:=20Rounds=20entity=20?= =?UTF-8?q?(event-level,=20class-tagged,=20dynamic)=20=E2=80=94=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Rounds entity to the event model: an additive, dynamic, class-tagged format-instance scoped to eligible class(es) with a seeding rule. - events.rs: RoundDef { id, label, classes, format, params, win_condition, seeding } + SeedingRule { FromRoster | FromRanking { source_round, top_n } } (FromRanking is the #84 carry seam, consumed in a later slice). EventMeta.rounds is additive (#[serde(default)], omit-when-empty). Dynamic CRUD through the #115 persist_meta_change path: add_round (generates the RoundId via slug+suffix), update_round, remove_round, rounds_of. Validation (RoundError → 404/400): each class is a directory class AND selected by the event; format is a known FormatRegistry::standard name; FromRanking source_round exists (and a round may not seed from itself). - engine/format.rs: FormatRegistry::standard() (the six production formats) + contains() — the single authority for valid format names. - app.rs: RD-gated POST/PUT/DELETE /events/{id}/rounds[/{round}] mirroring the classes/membership route shapes; typed 404 (UnknownScope) on unknown event/round, 400 (BadRequest) on invalid format/class/seeding. - bindings: RoundDef/SeedingRule/NewRoundReq/UpdateRoundReq + EventMeta.rounds, barrel exports. - tests: round add (id generated)/update/remove/list; validation rejects unknown format, *-demo fixtures, unknown/unselected class, dangling source_round; rounds_persist_across_a_restart; a rounds contract case. The Rounds UI is Slice 2b. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/EventMeta.ts | 18 +- bindings/NewRoundReq.ts | 39 ++ bindings/RoundDef.ts | 63 +++ bindings/SeedingRule.ts | 20 + bindings/UpdateRoundReq.ts | 35 ++ crates/engine/src/format.rs | 47 ++ crates/server/src/app.rs | 80 ++- crates/server/src/events.rs | 588 +++++++++++++++++++++++ frontend/contract/events.contract.ts | 185 ++++++- frontend/packages/types/src/generated.ts | 4 + 10 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 bindings/NewRoundReq.ts create mode 100644 bindings/RoundDef.ts create mode 100644 bindings/SeedingRule.ts create mode 100644 bindings/UpdateRoundReq.ts diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts index 0e3418e..89cf574 100644 --- a/bindings/EventMeta.ts +++ b/bindings/EventMeta.ts @@ -3,6 +3,7 @@ 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"; /** @@ -113,4 +114,19 @@ classes: Array, * **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, }; +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/NewRoundReq.ts b/bindings/NewRoundReq.ts new file mode 100644 index 0000000..c43f722 --- /dev/null +++ b/bindings/NewRoundReq.ts @@ -0,0 +1,39 @@ +// 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 { SeedingRule } from "./SeedingRule"; +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. + */ +win_condition: WinCondition, +/** + * How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. + */ +seeding: SeedingRule, }; diff --git a/bindings/RoundDef.ts b/bindings/RoundDef.ts new file mode 100644 index 0000000..4085650 --- /dev/null +++ b/bindings/RoundDef.ts @@ -0,0 +1,63 @@ +// 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 { RoundId } from "./RoundId"; +import type { SeedingRule } from "./SeedingRule"; +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)). + */ +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, }; diff --git a/bindings/SeedingRule.ts b/bindings/SeedingRule.ts new file mode 100644 index 0000000..72719dd --- /dev/null +++ b/bindings/SeedingRule.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 { 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). Derives serde + `ts_rs::TS`. + */ +export type SeedingRule = "FromRoster" | { "FromRanking": { +/** + * The prior round this round seeds from — must exist in [`EventMeta::rounds`]. + */ +source_round: RoundId, +/** + * How many of the source ranking's top places advance into this round. + */ +top_n: number, } }; diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts new file mode 100644 index 0000000..a915c2b --- /dev/null +++ b/bindings/UpdateRoundReq.ts @@ -0,0 +1,35 @@ +// 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 { SeedingRule } from "./SeedingRule"; +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. + */ +win_condition: WinCondition, +/** + * The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. + */ +seeding: SeedingRule, }; diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index a7e147c..a91e48c 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -375,11 +375,38 @@ 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), + /// [`single_elim`](crate::single_elim::SingleElim), + /// [`double_elim`](crate::double_elim::DoubleElim), + /// [`round_robin`](crate::round_robin::RoundRobin), and + /// [`multi_main`](crate::multi_main::MultiMain). + /// + /// 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); + crate::single_elim::SingleElim::register(&mut registry); + crate::double_elim::DoubleElim::register(&mut registry); + crate::round_robin::RoundRobin::register(&mut registry); + crate::multi_main::MultiMain::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() } + /// 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> { @@ -943,6 +970,26 @@ 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![ + "double_elim", + "multi_main", + "round_robin", + "single_elim", + "timed_qual", + "zippyq", + ] + ); + // The validation surface the server uses. + assert!(registry.contains("timed_qual")); + assert!(!registry.contains("knockout-demo")); + assert!(!registry.contains("no-such-format")); + } + #[test] fn registry_built_rolling_uses_the_rounds_param() { let mut registry = FormatRegistry::new(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index c89bd73..ef02829 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -91,8 +91,9 @@ use crate::classes::{Class, ClassError, ClassErrorKind, CreateClassRequest, Upda use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; use crate::events::{ - ActiveEvent, CreateEventRequest, EventMeta, EventRegistry, SetActiveEventRequest, - SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, + ActiveEvent, CreateEventRequest, EventMeta, EventRegistry, NewRoundReq, RoundDef, RoundError, + SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, + SetEventRosterRequest, UpdateRoundReq, }; use crate::live_state::live_state; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; @@ -103,6 +104,7 @@ 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. @@ -322,6 +324,15 @@ pub fn router(registry: EventRegistry) -> Router { "/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 **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)) @@ -841,6 +852,71 @@ async fn set_class_membership( 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)?; + 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)) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 35d86a1..6cbdf48 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -35,6 +35,9 @@ use std::sync::{Arc, RwLock}; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use gridfpv_engine::format::FormatRegistry; +use gridfpv_engine::scoring::WinCondition; +use gridfpv_events::RoundId; use gridfpv_storage::{InMemoryLog, SqliteLog}; use crate::app::AppState; @@ -165,6 +168,20 @@ pub struct EventMeta { /// #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 @@ -183,6 +200,124 @@ pub struct ClassMembership { pub pilots: Vec, } +/// 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)). + 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 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). Derives serde + `ts_rs::TS`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, 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 a prior round's ranking — the bracket / cut seam (issue #84). + /// The `source_round` must name another [`RoundDef`] in the same event's + /// [`rounds`](EventMeta::rounds); `top_n` is how many advance. Stored now; *consumed* when the + /// round actually runs (a later slice). + FromRanking { + /// The prior round this round seeds from — must exist in [`EventMeta::rounds`]. + source_round: RoundId, + /// How many of the source ranking's top places advance into this round. + top_n: usize, + }, +} + +/// 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. + pub win_condition: WinCondition, + /// How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. + #[serde(default)] + pub seeding: SeedingRule, +} + +/// 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. + pub win_condition: WinCondition, + /// The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. + #[serde(default)] + pub seeding: SeedingRule, +} + 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*, @@ -391,6 +526,7 @@ impl EventRegistry { roster: Vec::new(), classes: Vec::new(), classes_membership: Vec::new(), + rounds: Vec::new(), }, state: practice_state, }, @@ -630,6 +766,144 @@ impl EventRegistry { 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`], the `source_round` 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()))?; + + validate_round_fields( + &event.meta, + &directory, + &req.classes, + &req.format, + &req.seeding, + None, + )?; + + // 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, + win_condition: req.win_condition, + seeding: req.seeding, + }; + 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). + pub fn update_round( + &self, + id: &EventId, + round_id: &RoundId, + req: UpdateRoundReq, + ) -> 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()))?; + + if !event.meta.rounds.iter().any(|r| &r.id == round_id) { + return Err(RoundError::RoundNotFound(round_id.0.clone())); + } + validate_round_fields( + &event.meta, + &directory, + &req.classes, + &req.format, + &req.seeding, + Some(round_id), + )?; + + let round = RoundDef { + id: round_id.clone(), + label: req.label, + classes: req.classes, + format: req.format, + params: req.params, + win_condition: req.win_condition, + seeding: req.seeding, + }; + 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 { + 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 @@ -773,6 +1047,7 @@ impl EventRegistry { 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) @@ -927,6 +1202,92 @@ impl std::fmt::Display for RegistryError { 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.0) + } +} + +/// 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_round` does not +/// name an existing round in this event (excluding `editing` — a round may not seed from itself). +fn validate_round_fields( + meta: &EventMeta, + directory: &ClassDirectory, + classes: &[ClassId], + format: &str, + seeding: &SeedingRule, + editing: Option<&RoundId>, +) -> Result<(), RoundError> { + 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:?}"))); + } + + if let SeedingRule::FromRanking { source_round, .. } = seeding { + 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(()) +} + /// 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 { @@ -1448,4 +1809,231 @@ mod tests { 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: WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + } + } + + #[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 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: "single_elim".to_string(), + params: BTreeMap::from([("advance".to_string(), "2".to_string())]), + win_condition: WinCondition::FirstToLaps { n: 5 }, + seeding: SeedingRule::FromRoster, + }, + ) + .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, "single_elim"); + 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, "single_elim"); + + // 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. + let mut dangling = round_req("Bracket", vec![open.clone()]); + dangling.seeding = SeedingRule::FromRanking { + source_round: 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.seeding = SeedingRule::FromRanking { + source_round: q.id.clone(), + top_n: 4, + }; + let bracket = reg.add_round(&event.id, bracket).unwrap(); + assert_eq!( + bracket.seeding, + SeedingRule::FromRanking { + source_round: 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: bracket.win_condition, + seeding: SeedingRule::FromRanking { + source_round: bracket.id.clone(), + top_n: 2, + }, + }, + ); + assert!(matches!(self_ref, Err(RoundError::Invalid(_)))); + } + + #[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(); + } } diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index d336188..2b53a61 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -21,7 +21,7 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Class, Command, EventMeta, Pilot, Timer } from '@gridfpv/types'; +import type { Class, Command, EventMeta, Pilot, RoundDef, Timer } from '@gridfpv/types'; import { type Director } from '../test-harness/director.ts'; import { eventRoot, startContractDirector } from './harness.ts'; @@ -982,3 +982,186 @@ describe('race Slice 1a: per-class membership', () => { expect((await setMembership(event.id, 'mgp-open', [a])).status).toBe(401); }); }); + +/** + * Race redesign Slice 2a: **rounds** — event-level, class-tagged, *dynamic* format-instances. A + * `RoundDef` scopes a `FormatRegistry` format (+ its config + win condition) to one or more eligible + * classes, with a `SeedingRule` (default `FromRoster`; `FromRanking` is the #84 carry seam). Rounds + * are recorded on `EventMeta.rounds` (additive, omitted from the wire when empty). The Rounds UI is + * Slice 2b — these guard the backend wire only. + * + * guards: + * - a new event's `EventMeta.rounds` is absent (additive `#[serde(default)]`, omit-when-empty). + * - `POST /events/{id}/rounds` is **RD-gated** (no token → 401), auto-generates the round id, and + * returns the created `RoundDef`. Each class must be selected by the event, the format must be a + * known registry name, and a `FromRanking` source must exist (bad → 400 `BadRequest`). + * - `PUT /events/{id}/rounds/{roundId}` replaces the round wholesale; `DELETE` removes it; an + * unknown round id → 404 `UnknownScope`. + */ +describe('race Slice 2a: rounds', () => { + /** `POST /events/{id}/rounds` with a body + optional token → raw status + parsed body. */ + async function addRound( + eventId: string, + body: unknown, + token?: string + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${eventRoot(director.baseUrl, eventId)}/rounds`, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + } + + /** `PUT`/`DELETE /events/{id}/rounds/{roundId}` → raw status + parsed body. */ + async function mutateRound( + eventId: string, + roundId: string, + method: 'PUT' | 'DELETE', + body?: unknown, + token: string = TOKEN + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; + const res = await fetch( + `${eventRoot(director.baseUrl, eventId)}/rounds/${encodeURIComponent(roundId)}`, + { method, headers, body: body === undefined ? undefined : JSON.stringify(body) } + ); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + } + + /** Select the built-in `mgp-open` class on an event so a round may run for it. */ + async function selectOpen(eventId: string): Promise { + await fetch(`${eventRoot(director.baseUrl, eventId)}/classes`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ ids: ['mgp-open'] }) + }); + } + + it('a new event has no rounds (additive, omit-when-empty)', async () => { + const event = (await createEvent('Rounds Default', TOKEN)).body as EventMeta; + expect(event.rounds).toBeUndefined(); + }); + + it('POST /rounds is RD-gated — no token → 401', async () => { + const event = (await createEvent('Rounds Auth', TOKEN)).body as EventMeta; + await selectOpen(event.id); + const res = await addRound(event.id, { + label: 'Q1', + classes: ['mgp-open'], + format: 'timed_qual', + win_condition: 'BestLap' + }); + expect(res.status).toBe(401); + }); + + it('POST /rounds generates an id and records the round; PUT/DELETE edit it', async () => { + const event = (await createEvent('Rounds CRUD', TOKEN)).body as EventMeta; + await selectOpen(event.id); + + const created = await addRound( + event.id, + { + label: 'Qualifying R1', + classes: ['mgp-open'], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap' + }, + TOKEN + ); + expect(created.status).toBe(200); + const round = created.body as RoundDef; + expect(round.id).toMatch(/^qualifying-r1-/); + expect(round.label).toBe('Qualifying R1'); + expect(round.format).toBe('timed_qual'); + expect(round.seeding).toBe('FromRoster'); // default + + // The round is recorded on the event meta (re-create returns the current snapshot via list). + const list = (await fetch(`${director.baseUrl}/events`).then((r) => r.json())) as EventMeta[]; + const meta = list.find((e) => e.id === event.id)!; + expect((meta.rounds ?? []).map((r) => r.id)).toContain(round.id); + + // PUT replaces wholesale (id preserved). + const updated = await mutateRound(event.id, round.id, 'PUT', { + label: 'Open Qualifying', + classes: ['mgp-open'], + format: 'single_elim', + params: { advance: '2' }, + win_condition: { FirstToLaps: { n: 5 } } + }); + expect(updated.status).toBe(200); + const updatedRound = updated.body as RoundDef; + expect(updatedRound.id).toBe(round.id); + expect(updatedRound.format).toBe('single_elim'); + + // DELETE removes it; a second DELETE is a 404 UnknownScope. + expect((await mutateRound(event.id, round.id, 'DELETE')).status).toBe(200); + const gone = await mutateRound(event.id, round.id, 'DELETE'); + expect(gone.status).toBe(404); + expect((gone.body as { code?: string }).code).toBe('UnknownScope'); + }); + + it('POST /rounds validates format, class selection, and seeding source → 400', async () => { + const event = (await createEvent('Rounds Validation', TOKEN)).body as EventMeta; + await selectOpen(event.id); + + // Unknown format → 400 BadRequest. + const badFormat = await addRound( + event.id, + { label: 'X', classes: ['mgp-open'], format: 'no-such-format', win_condition: 'BestLap' }, + TOKEN + ); + expect(badFormat.status).toBe(400); + expect((badFormat.body as { code?: string }).code).toBe('BadRequest'); + + // A directory class the event does NOT select (mgp-7 is a built-in, but only mgp-open is + // selected here) → 400. + const badClass = await addRound( + event.id, + { label: 'X', classes: ['mgp-7'], format: 'timed_qual', win_condition: 'BestLap' }, + TOKEN + ); + expect(badClass.status).toBe(400); + + // FromRanking with a dangling source_round → 400. + const dangling = await addRound( + event.id, + { + label: 'Bracket', + classes: ['mgp-open'], + format: 'single_elim', + win_condition: 'BestLap', + seeding: { FromRanking: { source_round: 'does-not-exist', top_n: 4 } } + }, + TOKEN + ); + expect(dangling.status).toBe(400); + + // An unknown event id → 404 UnknownScope. + const badEvent = await addRound( + 'no-such-event', + { label: 'X', classes: ['mgp-open'], format: 'timed_qual', win_condition: 'BestLap' }, + TOKEN + ); + expect(badEvent.status).toBe(404); + expect((badEvent.body as { code?: string }).code).toBe('UnknownScope'); + }); +}); diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 7d7772a..54652bb 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -62,6 +62,7 @@ export type * from '@bindings/LapList'; export type * from '@bindings/LiveRaceState'; export type * from '@bindings/LogRef'; export type * from '@bindings/Metric'; +export type * from '@bindings/NewRoundReq'; export type * from '@bindings/OptionalEdit'; export type * from '@bindings/Pass'; export type * from '@bindings/Penalty'; @@ -73,8 +74,10 @@ export type * from '@bindings/ProjectionBody'; export type * from '@bindings/ProjectionKind'; export type * from '@bindings/ProtocolError'; export type * from '@bindings/RankEntry'; +export type * from '@bindings/RoundDef'; export type * from '@bindings/RoundId'; export type * from '@bindings/Scope'; +export type * from '@bindings/SeedingRule'; export type * from '@bindings/ServerHello'; export type * from '@bindings/SessionId'; export type * from '@bindings/SetActiveEventRequest'; @@ -93,6 +96,7 @@ export type * from '@bindings/TimerKind'; export type * from '@bindings/TimerStatus'; export type * from '@bindings/UpdateClassRequest'; export type * from '@bindings/UpdatePilotRequest'; +export type * from '@bindings/UpdateRoundReq'; export type * from '@bindings/UpdateTimerRequest'; export type * from '@bindings/VtxType'; export type * from '@bindings/WinCondition'; From 6f8d4ad0583f61a496df03b522db227f6d8dda64 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 03:52:17 +0000 Subject: [PATCH 105/362] Race Slice 2b: GET /formats + round client/session senders Add the backend single-source-of-truth for the Rounds UI's format dropdown and the frontend plumbing the screen drives: - `GET /formats` (crates/server): an open read returning the engine's `FormatRegistry::standard()` names (the same set `POST /rounds` validates against), so the UI never hard-codes the 6 production formats. Contract case added; no bindings regen (a plain `Vec`). - protocol-client: `listFormats` + `createRound`/`updateRound`/`deleteRound` senders (POST/PUT/DELETE `/events/{id}/rounds`), mirroring the classes/ membership senders, typed against the Slice 2a `NewRoundReq`/`UpdateRoundReq`/ `RoundDef` bindings. - session.svelte.ts: `listFormats` + `createRound`/`updateRound`/`deleteRound`, re-homing `currentEvent` after each write (create/update splice the RoundDef into `rounds`; delete takes the returned EventMeta) so the stage stays live. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/server/src/app.rs | 19 +++ .../apps/rd-console/src/lib/session.svelte.ts | 89 ++++++++++++++ frontend/contract/events.contract.ts | 17 +++ .../packages/protocol-client/src/client.ts | 112 ++++++++++++++++++ .../packages/protocol-client/src/index.ts | 4 + 5 files changed, 241 insertions(+) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index ef02829..c096fa0 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -79,6 +79,7 @@ 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; use gridfpv_engine::scoring::{WinCondition, score_events}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{LapList, lap_list_marshaled, registrations}; @@ -314,6 +315,10 @@ pub fn router(registry: EventRegistry) -> Router { "/classes/{class_id}", put(update_class).delete(delete_class), ) + // 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)) // 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)) @@ -726,6 +731,20 @@ async fn list_classes(State(registry): State) -> Json> Json(registry.classes().list()) } +/// `GET /formats` — the valid **format names** (race redesign Slice 2b). +/// +/// The single source of truth the Rounds UI's format dropdown reads: the production formats +/// registered in [`FormatRegistry::standard`], in sorted order. An open read (no token) — these +/// are static, compiled-in configuration, not per-event state, so no registry state is touched. +async fn list_formats() -> Json> { + let formats = FormatRegistry::standard() + .names() + .into_iter() + .map(String::from) + .collect(); + Json(formats) +} + /// `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 diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 3b4c945..e67624c 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -61,6 +61,10 @@ import { deleteClass, setEventClasses, setClassMembership, + listFormats, + createRound, + updateRound, + deleteRound, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -83,12 +87,16 @@ import type { LiveRaceState, Pilot, PilotId, + NewRoundReq, ProjectionBody, + RoundDef, + RoundId, Scope, Timer, TimerId, UpdateClassRequest, UpdatePilotRequest, + UpdateRoundReq, UpdateTimerRequest } from '@gridfpv/types'; @@ -243,6 +251,10 @@ export class Session { #deleteClassImpl: typeof deleteClass; #setEventClassesImpl: typeof setEventClasses; #setClassMembershipImpl: typeof setClassMembership; + #listFormatsImpl: typeof listFormats; + #createRoundImpl: typeof createRound; + #updateRoundImpl: typeof updateRound; + #deleteRoundImpl: typeof deleteRound; constructor(opts?: { connectImpl?: typeof connect; @@ -270,6 +282,10 @@ export class Session { deleteClassImpl?: typeof deleteClass; setEventClassesImpl?: typeof setEventClasses; setClassMembershipImpl?: typeof setClassMembership; + listFormatsImpl?: typeof listFormats; + createRoundImpl?: typeof createRound; + updateRoundImpl?: typeof updateRound; + deleteRoundImpl?: typeof deleteRound; baseUrl?: string; autoRestore?: boolean; }) { @@ -298,6 +314,10 @@ export class Session { this.#deleteClassImpl = opts?.deleteClassImpl ?? deleteClass; this.#setEventClassesImpl = opts?.setEventClassesImpl ?? setEventClasses; this.#setClassMembershipImpl = opts?.setClassMembershipImpl ?? setClassMembership; + this.#listFormatsImpl = opts?.listFormatsImpl ?? listFormats; + this.#createRoundImpl = opts?.createRoundImpl ?? createRound; + this.#updateRoundImpl = opts?.updateRoundImpl ?? updateRound; + this.#deleteRoundImpl = opts?.deleteRoundImpl ?? deleteRound; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -607,6 +627,75 @@ export class Session { return updated; } + // --- Rounds (race redesign Slice 2b) ---------------------------------------------------------- + // The event's rounds are event-level, class-tagged, dynamic format-instances. Reads of the valid + // **format names** are open (`GET /formats`); the add/update/remove writes are control-gated and + // re-home `currentEvent` so the Rounds stage stays in sync, mirroring the class/roster writes. + + /** + * List the valid **format names** (`GET /formats`, open, no token) — race redesign Slice 2b. The + * single source of truth (the engine's `FormatRegistry::standard()`, sorted) the Rounds UI's + * format dropdown reads, rather than a hard-coded list. Rejects on a transport/HTTP failure. + */ + listFormats(): Promise { + return this.#listFormatsImpl(this.baseUrl, { token: this.#token }); + } + + /** + * Add a **round** to the current event (`POST /events/{id}/rounds`) — race redesign Slice 2b. + * RD-gated; the round id is auto-generated server-side. No-op (resolves `undefined`) when no event + * is selected. On success the created {@link RoundDef} is appended to {@link currentEvent}'s + * rounds so the stage reflects the add immediately; returns the new round, `undefined` on a + * cancelled prompt, or throws (a bad class / format / seeding is a **400**). + */ + async createRound(request: NewRoundReq): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const round = await this.#privilegedWrite((token) => + this.#createRoundImpl(this.baseUrl, event.id, request, token) + ); + if (round) { + this.currentEvent = { ...event, rounds: [...(event.rounds ?? []), round] }; + } + return round; + } + + /** + * Replace an existing **round**'s fields (`PUT /events/{id}/rounds/{round}`) — race redesign Slice + * 2b. RD-gated; the id is not editable and every other field is replaced wholesale. No-op + * (resolves `undefined`) when no event is selected. On success the updated {@link RoundDef} + * replaces its entry in {@link currentEvent}'s rounds; returns it, `undefined` on a cancelled + * prompt, or throws (a bad class / format / seeding is a **400**; an unknown round a **404**). + */ + async updateRound(roundId: RoundId, request: UpdateRoundReq): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const round = await this.#privilegedWrite((token) => + this.#updateRoundImpl(this.baseUrl, event.id, roundId, request, token) + ); + if (round) { + const rounds = (event.rounds ?? []).map((r) => (r.id === roundId ? round : r)); + this.currentEvent = { ...event, rounds }; + } + return round; + } + + /** + * Remove a **round** from the current event (`DELETE /events/{id}/rounds/{round}`) — race redesign + * Slice 2b. RD-gated. No-op (resolves `undefined`) when no event is selected. On success the + * updated {@link EventMeta} replaces {@link currentEvent} so the stage drops the round; returns + * it, `undefined` on a cancelled prompt, or throws (an unknown round is a **404**). + */ + async deleteRound(roundId: RoundId): Promise { + const event = this.currentEvent; + if (!event) return undefined; + const updated = await this.#privilegedWrite((token) => + this.#deleteRoundImpl(this.baseUrl, event.id, roundId, token) + ); + if (updated) this.currentEvent = updated; + return updated; + } + /** * Bind a timing-source competitor to a pilot for lap attribution (`Command::Register`) — the * IRL **binding** step of the Roster stage. A timing source (RotorHazard node, sim player) reports diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 2b53a61..879646e 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -1164,4 +1164,21 @@ describe('race Slice 2a: rounds', () => { expect(badEvent.status).toBe(404); expect((badEvent.body as { code?: string }).code).toBe('UnknownScope'); }); + + it('GET /formats is an open read of the standard format names (the round dropdown source)', async () => { + // The Rounds UI sources its format dropdown here rather than hard-coding the list — the single + // source of truth is the engine's `FormatRegistry::standard()`. An open read (no token). + const res = await fetch(`${director.baseUrl}/formats`); + expect(res.status).toBe(200); + const names = (await res.json()) as string[]; + // The 6 production formats, in sorted order (the same set `POST /rounds` validates against). + expect(names).toEqual([ + 'double_elim', + 'multi_main', + 'round_robin', + 'single_elim', + 'timed_qual', + 'zippyq' + ]); + }); }); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 14c9a1a..c0755f4 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -29,10 +29,13 @@ import type { Cursor, EventId, EventMeta, + NewRoundReq, Pilot, PilotId, ProjectionBody, ProtocolError, + RoundDef, + RoundId, Scope, Snapshot, SubscribeRequest, @@ -40,6 +43,7 @@ import type { TimerId, UpdateClassRequest, UpdatePilotRequest, + UpdateRoundReq, UpdateTimerRequest } from '@gridfpv/types'; @@ -790,6 +794,114 @@ export async function setClassMembership( return (await resp.json()) as EventMeta; } +/** + * List the valid **format names** (`GET /formats`) — race redesign Slice 2b. An open read (no + * token): the production formats registered in the engine's `FormatRegistry::standard()`, the + * single source of truth the Rounds UI's format dropdown reads. Resolves to the names in sorted + * order, or rejects on a non-2xx / transport failure. + */ +export async function listFormats( + baseUrl: string, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}/formats`, { headers }); + if (!resp.ok) throw new Error(`GET /formats failed: HTTP ${resp.status}`); + return (await resp.json()) as string[]; +} + +/** + * Add a **round** to an event (`POST /events/{id}/rounds`) — race redesign Slice 2b. RD-gated; the + * round id is auto-generated server-side. The server validates each class is selected by the event, + * the `format` is a known {@link listFormats} name, and a `FromRanking` seeding source names an + * existing round (else **400**); an unknown event is **404**. Resolves to the created + * {@link RoundDef} (with its generated id), or rejects on a non-2xx / transport failure. + */ +export async function createRound( + baseUrl: string, + eventId: EventId, + request: NewRoundReq, + token?: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}${eventRoot(eventId)}/rounds`, { + method: 'POST', + headers, + body: JSON.stringify(request) + }); + if (!resp.ok) throw new Error(`POST /events/${eventId}/rounds failed: HTTP ${resp.status}`); + return (await resp.json()) as RoundDef; +} + +/** + * Replace an existing **round**'s fields (`PUT /events/{id}/rounds/{round}`) — race redesign Slice + * 2b. RD-gated; the round id is the path segment (not editable) and every other field is replaced + * wholesale. Same validation as {@link createRound} (bad class / format / seeding → **400**); an + * unknown event or round id is **404**. Resolves to the updated {@link RoundDef}, or rejects on a + * non-2xx / transport failure. + */ +export async function updateRound( + baseUrl: string, + eventId: EventId, + roundId: RoundId, + request: UpdateRoundReq, + token?: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json' + }; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchImpl( + `${trimSlash(baseUrl)}${eventRoot(eventId)}/rounds/${encodeURIComponent(roundId)}`, + { + method: 'PUT', + headers, + body: JSON.stringify(request) + } + ); + if (!resp.ok) + throw new Error(`PUT /events/${eventId}/rounds/${roundId} failed: HTTP ${resp.status}`); + return (await resp.json()) as RoundDef; +} + +/** + * Remove a **round** from an event (`DELETE /events/{id}/rounds/{round}`) — race redesign Slice 2b. + * RD-gated; an unknown event or round id is **404**. Resolves to the event's updated + * {@link EventMeta}, or rejects on a non-2xx / transport failure. + */ +export async function deleteRound( + baseUrl: string, + eventId: EventId, + roundId: RoundId, + token?: string, + options: { fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchImpl( + `${trimSlash(baseUrl)}${eventRoot(eventId)}/rounds/${encodeURIComponent(roundId)}`, + { + method: 'DELETE', + headers + } + ); + if (!resp.ok) + throw new Error(`DELETE /events/${eventId}/rounds/${roundId} failed: HTTP ${resp.status}`); + return (await resp.json()) as EventMeta; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 9de8ddb..bc40ef9 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -37,6 +37,10 @@ export { deleteClass, setEventClasses, setClassMembership, + listFormats, + createRound, + updateRound, + deleteRound, PRACTICE_EVENT_ID } from './client.js'; export type { From dc9d05824e2e01509571f846e1a4998f3bc790f7 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 03:52:29 +0000 Subject: [PATCH 106/362] Race Slice 2b: Rounds UI (Rounds & Heats stage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the "Rounds & Heats" workspace tab (after Roster, before Live control) and fill its Rounds half — the dynamic round manager. - route.ts + App.svelte: new `rounds` WorkspaceTab / SCREEN "Rounds & Heats" (Alt-4; Live/Marshaling/Results/Timers renumbered). - EventRounds.svelte: lists the event's rounds (index, label, eligible class names resolved via listClasses, format, win-condition + seeding summaries) and an add/edit form — label; eligible-classes multi-select of the event's selected classes (one = class round, all = open/practice hint); format dropdown from GET /formats; WinCondition selector (Timed/FirstToLaps/BestLap/ BestConsecutive with the right numeric knob); seeding selector defaulting to From roster, revealing source_round + top_n on From ranking; a minimal optional params key→value editor. Dynamic add reflects immediately; edit (PUT) and remove (DELETE) supported. Field-readable: large text, dark. The Heats half is a labelled "Heat building — Slice 3" placeholder. - Unit tests (EventRounds.test.ts) for list / add / FromRanking selector / edit / remove / placeholder, plus the round seams wired into test support. - Browser e2e (rounds.spec.ts): real click-through — select a class, add a round, assert it persists across reload, edit it, remove it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 27 +- frontend/apps/rd-console/src/lib/route.ts | 4 +- .../rd-console/src/screens/EventRounds.svelte | 615 ++++++++++++++++++ .../apps/rd-console/tests/EventRounds.test.ts | 176 +++++ frontend/apps/rd-console/tests/support.ts | 19 +- frontend/e2e/rounds.spec.ts | 126 ++++ 6 files changed, 959 insertions(+), 8 deletions(-) create mode 100644 frontend/apps/rd-console/src/screens/EventRounds.svelte create mode 100644 frontend/apps/rd-console/tests/EventRounds.test.ts create mode 100644 frontend/e2e/rounds.spec.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index bfb299d..40818ed 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -39,6 +39,7 @@ import EventTimers from './screens/EventTimers.svelte'; import EventRoster from './screens/EventRoster.svelte'; import EventClasses from './screens/EventClasses.svelte'; + import EventRounds from './screens/EventRounds.svelte'; import LiveRaceControl from './screens/LiveRaceControl.svelte'; import Marshaling from './screens/Marshaling.svelte'; import Results from './screens/Results.svelte'; @@ -123,7 +124,15 @@ } }); - type ScreenId = 'setup' | 'timers' | 'roster' | 'classes' | 'live' | 'marshaling' | 'results'; + type ScreenId = + | 'setup' + | 'timers' + | 'roster' + | 'classes' + | 'rounds' + | 'live' + | 'marshaling' + | 'results'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ { id: 'setup', label: 'Setup', key: '1', icon: 'M4 6h16M4 12h16M4 18h10' }, { @@ -138,13 +147,19 @@ key: '3', icon: 'M4 6h16M4 12h10M4 18h7M18 14l2 2 3-4' }, - { id: 'live', label: 'Live control', key: '4', icon: 'M5 3l14 9-14 9V3z' }, - { id: 'marshaling', label: 'Marshaling', key: '5', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, - { id: 'results', label: 'Results', key: '6', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, + { + id: 'rounds', + label: 'Rounds & Heats', + key: '4', + icon: 'M4 5h16M4 12h16M4 19h16M9 5v14' + }, + { id: 'live', label: 'Live control', key: '5', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'marshaling', label: 'Marshaling', key: '6', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, + { id: 'results', label: 'Results', key: '7', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, { id: 'timers', label: 'Timers', - key: '7', + key: '8', icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z' } ]; @@ -352,6 +367,8 @@ {:else if active === 'classes'} + {:else if active === 'rounds'} + {:else if active === 'live'} {:else if active === 'marshaling'} diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index c0fd555..0f12655 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -17,7 +17,7 @@ * - `#/events` → the Events page (the picker) * - `#/timers` → the Timers page * - `#/event/` → the in-event workspace on `` - * (tab ∈ setup | roster | classes | live | marshaling | results | timers) + * (tab ∈ setup | roster | classes | rounds | live | marshaling | results | timers) * * The active event itself is **app-wide server state** (the Director's active event, #90), so the * hash only restores *which tab* of the workspace — not *which* event. The workspace always shows @@ -33,6 +33,7 @@ export type WorkspaceTab = | 'setup' | 'roster' | 'classes' + | 'rounds' | 'live' | 'marshaling' | 'results' @@ -49,6 +50,7 @@ export const WORKSPACE_TABS: readonly WorkspaceTab[] = [ 'setup', 'roster', 'classes', + 'rounds', 'live', 'marshaling', 'results', diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte new file mode 100644 index 0000000..f6496bc --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -0,0 +1,615 @@ + + +
      + + {#snippet actions()} + + {/snippet} + + {#if eventClasses.length === 0} +

      + This event selects no classes yet. Pick classes in the Classes stage first — + a round runs for one or more of them. +

      + {:else if rounds.length === 0} +

      No rounds yet. Add the first round to get going.

      + {/if} + + {#if rounds.length > 0} +
        + {#each rounds as round, i (round.id)} +
      1. + +
        +
        + {round.label} + {round.format} +
        +
        + + {round.classes.length === eventClasses.length && eventClasses.length > 1 + ? 'All classes' + : round.classes.map(className).join(', ') || '—'} + + {winSummary(round.win_condition)} + {seedSummary(round.seeding)} +
        +
        +
        + + +
        +
      2. + {/each} +
      + {/if} + + {#if formOpen} +
      { + e.preventDefault(); + submit(); + }} + > +

      {editing ? 'Edit round' : 'New round'}

      + + + + + + +
      + {#each eventClasses as cls (cls.id)} + + {/each} +
      +
      + +
      + + + + + + + + + {#if winKind === 'Timed'} + + + + {:else if winKind === 'FirstToLaps' || winKind === 'BestConsecutive'} + + + + {/if} +
      + + + + + + {#if seedKind === 'FromRanking'} +
      + + {#if sourceCandidates.length === 0} +

      Add another round first to seed from its ranking.

      + {:else} + + {/if} +
      + + + +
      + {/if} + + +
      + {#each params as row, i (i)} +
      + + + +
      + {/each} + +
      +
      + +
      + + +
      +
      + {/if} +
      + + +

      + Heat building — Slice 3. Once a round is defined, this is where its field gets + drawn into heats and slotted to channels. Not yet available. +

      +
      +
      + + diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts new file mode 100644 index 0000000..6863873 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { Class, EventMeta, RoundDef } from '@gridfpv/types'; +import EventRounds from '../src/screens/EventRounds.svelte'; +import { makeTestSession } from './support.js'; + +const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; +const SPEC: Class = { id: 'c2', name: 'Spec', source: 'Custom' }; + +const QUAL: RoundDef = { + id: 'r1', + label: 'Qualifying R1', + classes: ['c1'], + format: 'timed_qual', + params: {}, + win_condition: { Timed: { window_micros: 120_000_000 } }, + seeding: 'FromRoster' +}; + +/** An event selecting both classes, with one existing round. */ +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + roster: [], + classes: ['c1', 'c2'], + rounds: [QUAL] +}; + +const FORMATS = ['double_elim', 'multi_main', 'round_robin', 'single_elim', 'timed_qual', 'zippyq']; + +function baseImpls() { + return { + listClassesImpl: vi.fn(async () => [OPEN, SPEC]), + listFormatsImpl: vi.fn(async () => FORMATS) + }; +} + +describe('EventRounds (define rounds — classes, format, seeding)', () => { + it('lists the event rounds with resolved class names, format, win condition, and seeding', async () => { + const { session } = makeTestSession({ ...baseImpls(), event: EVENT }); + render(EventRounds, { session }); + + await screen.findByText('Qualifying R1'); + // Format and the resolved class name show; FromRoster summarises as "From roster". + expect(screen.getByText('timed_qual')).toBeInTheDocument(); + expect(screen.getByText('Open')).toBeInTheDocument(); + expect(screen.getByText('From roster')).toBeInTheDocument(); + expect(screen.getByText(/Timed · 120s/)).toBeInTheDocument(); + // The round index renders. + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('adds a round via createRound and reflects it immediately', async () => { + const impls = baseImpls(); + const created: RoundDef = { + id: 'r2', + label: 'Open Practice', + classes: ['c1', 'c2'], + format: 'zippyq', + params: {}, + win_condition: 'BestLap', + seeding: 'FromRoster' + }; + const createRoundImpl = vi.fn(async () => created); + const { session } = makeTestSession({ + ...impls, + createRoundImpl, + event: { ...EVENT, rounds: [] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + + await fireEvent.input(await screen.findByLabelText('Label'), { + target: { value: 'Open Practice' } + }); + // Tick both classes (open / practice). + await fireEvent.click(screen.getByLabelText('Eligible Open')); + await fireEvent.click(screen.getByLabelText('Eligible Spec')); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'zippyq' } }); + await fireEvent.change(screen.getByLabelText('Win condition'), { + target: { value: 'BestLap' } + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Add round' })); + + await waitFor(() => expect(createRoundImpl).toHaveBeenCalledTimes(1)); + const [, eventId, req] = createRoundImpl.mock.calls[0]; + expect(eventId).toBe('e1'); + expect(req).toMatchObject({ + label: 'Open Practice', + classes: ['c1', 'c2'], + format: 'zippyq', + win_condition: 'BestLap', + seeding: 'FromRoster' + }); + // The new round appears in the list. + await screen.findByText('Open Practice'); + }); + + it('reveals the FromRanking selector (source round + top N) and authors it', async () => { + const impls = baseImpls(); + const updated: RoundDef = { + ...QUAL, + id: 'r2', + label: 'Mains', + format: 'single_elim', + seeding: { FromRanking: { source_round: 'r1', top_n: 8 } } + }; + const createRoundImpl = vi.fn(async () => updated); + const { session } = makeTestSession({ ...impls, createRoundImpl, event: EVENT }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Mains' } }); + await fireEvent.click(screen.getByLabelText('Eligible Open')); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'single_elim' } }); + + // No source-round dropdown until From ranking is chosen. + expect(screen.queryByLabelText('Source round')).not.toBeInTheDocument(); + await fireEvent.change(screen.getByLabelText('Seeding'), { + target: { value: 'FromRanking' } + }); + // The source-round selector reveals, listing the existing round. + const source = (await screen.findByLabelText('Source round')) as HTMLSelectElement; + await fireEvent.change(source, { target: { value: 'r1' } }); + await fireEvent.input(screen.getByLabelText('Top N'), { target: { value: '4' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Add round' })); + + await waitFor(() => expect(createRoundImpl).toHaveBeenCalledTimes(1)); + const [, , req] = createRoundImpl.mock.calls[0]; + expect(req.seeding).toEqual({ FromRanking: { source_round: 'r1', top_n: 4 } }); + }); + + it('edits an existing round via updateRound, seeded from its current fields', async () => { + const impls = baseImpls(); + const updateRoundImpl = vi.fn(async (_b, _e, _id, req) => ({ ...QUAL, ...req })); + const { session } = makeTestSession({ ...impls, updateRoundImpl, event: EVENT }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const label = (await screen.findByLabelText('Label')) as HTMLInputElement; + expect(label.value).toBe('Qualifying R1'); + await fireEvent.input(label, { target: { value: 'Qualifying R2' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save round' })); + + await waitFor(() => expect(updateRoundImpl).toHaveBeenCalledTimes(1)); + const [, , roundId, req] = updateRoundImpl.mock.calls[0]; + expect(roundId).toBe('r1'); + expect(req.label).toBe('Qualifying R2'); + }); + + it('removes a round via deleteRound', async () => { + const impls = baseImpls(); + const deleteRoundImpl = vi.fn(async () => ({ ...EVENT, rounds: [] })); + const { session } = makeTestSession({ ...impls, deleteRoundImpl, event: EVENT }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(deleteRoundImpl).toHaveBeenCalledTimes(1)); + expect(deleteRoundImpl.mock.calls[0][2]).toBe('r1'); + await waitFor(() => expect(session.currentEvent?.rounds).toEqual([])); + }); + + it('shows the Slice 3 Heats placeholder', async () => { + const { session } = makeTestSession({ ...baseImpls(), event: EVENT }); + render(EventRounds, { session }); + await screen.findByText(/Heat building — Slice 3/i); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 9ec2be8..7f0b1ea 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -26,7 +26,11 @@ import type { updateClass, deleteClass, setEventClasses, - setClassMembership + setClassMembership, + listFormats, + createRound, + updateRound, + deleteRound } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -70,6 +74,12 @@ export interface TimerImpls { setEventClassesImpl?: typeof setEventClasses; /** The per-class membership write seam (race redesign Slice 1b) — backs the EventRoster tests. */ setClassMembershipImpl?: typeof setClassMembership; + /** The valid format-names read (race redesign Slice 2b) — backs the EventRounds tests. */ + listFormatsImpl?: typeof listFormats; + /** The per-event round write seams (race redesign Slice 2b) — back the EventRounds tests. */ + createRoundImpl?: typeof createRound; + updateRoundImpl?: typeof updateRound; + deleteRoundImpl?: typeof deleteRound; } export interface TestSession { @@ -137,7 +147,12 @@ export function makeTestSession( deleteClassImpl: opts?.deleteClassImpl, setEventClassesImpl: opts?.setEventClassesImpl, // Per-class membership write seam (race redesign Slice 1b): inert unless a test overrides it. - setClassMembershipImpl: opts?.setClassMembershipImpl + setClassMembershipImpl: opts?.setClassMembershipImpl, + // Round read/write seams (race redesign Slice 2b): inert unless a test overrides them. + listFormatsImpl: opts?.listFormatsImpl, + createRoundImpl: opts?.createRoundImpl, + updateRoundImpl: opts?.updateRoundImpl, + deleteRoundImpl: opts?.deleteRoundImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/e2e/rounds.spec.ts b/frontend/e2e/rounds.spec.ts new file mode 100644 index 0000000..d48a71b --- /dev/null +++ b/frontend/e2e/rounds.spec.ts @@ -0,0 +1,126 @@ +/** + * Round definition through the console UI (race redesign Slice 2b) — the deliverable proof for the + * Rounds half of the "Rounds & Heats" stage. + * + * A real click-through against a **real** Director (open / no token, full-trust by default): enter + * an event (Practice), make sure it has ≥1 eligible class (select the built-in **Open Class**), open + * the workspace's **Rounds & Heats** tab, then **add** a round (label + eligible class + format + + * win condition + seeding), confirm it lists and **persists across a reload**, **edit** its label, + * and **remove** it. Cleans up the class selection at the end. + * + * Every step is a real click/input in headless chromium on the real `POST/PUT/DELETE + * /events/{id}/rounds` + `GET /formats` paths — nothing mocked. Importing `test`/`expect` from + * `./observability.js` means a failure carries the full-stack dump (browser console, page errors, + * the Director's server log). + */ +import { expect, test } from './observability.js'; + +/** Get the shared worker Director into the Practice event's workspace. */ +async function enterPractice(page: import('@playwright/test').Page) { + const liveNav = page.getByRole('button', { name: /Live control/ }); + const eventsCard = page.getByRole('button', { name: /Events/ }); + await expect(liveNav.or(eventsCard).first()).toBeVisible({ timeout: 15_000 }); + if (!(await liveNav.isVisible().catch(() => false))) { + await eventsCard.click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await picker.isVisible().catch(() => false)) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + } +} + +async function openTab(page: import('@playwright/test').Page, name: string) { + await page.getByRole('navigation', { name: 'Screens' }).getByRole('button', { name }).click(); +} + +test('RD defines a round (class, format, seeding), it persists, then edits and removes it', async ({ + page +}) => { + const LABEL = `E2E-Round-${Date.now()}`; + await page.goto('/'); + await enterPractice(page); + + // ── Make sure the event has an eligible class: select the built-in Open Class. ────────────── + await openTab(page, 'Classes'); + await expect(page.getByRole('heading', { name: 'Classes for this event' })).toBeVisible({ + timeout: 15_000 + }); + const classRow = page + .getByRole('list', { name: 'Class directory' }) + .getByRole('listitem') + .filter({ hasText: 'Open Class' }); + const classBox = classRow.getByRole('checkbox', { name: 'Select Open Class' }); + if (!(await classBox.isChecked())) await classBox.check(); + await page.getByRole('button', { name: 'Save classes' }).click(); + await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ + timeout: 15_000 + }); + + // ── Rounds & Heats tab → add a round ──────────────────────────────────────────────────────── + await openTab(page, 'Rounds & Heats'); + await expect(page.getByRole('heading', { name: 'Rounds', exact: true })).toBeVisible({ + timeout: 15_000 + }); + + await page.getByRole('button', { name: '+ Add round' }).click(); + const form = page.getByRole('form', { name: 'Add round' }); + await expect(form).toBeVisible(); + await form.getByLabel('Label').fill(LABEL); + await form.getByLabel('Eligible Open Class').check(); + await form.getByLabel('Format').selectOption('timed_qual'); + await form.getByLabel('Win condition').selectOption('BestLap'); + await page.getByRole('button', { name: 'Add round', exact: true }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // The new round lists, with its format and the resolved class name. + const list = page.getByRole('list').filter({ hasText: LABEL }); + const row = list.getByRole('listitem').filter({ hasText: LABEL }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row.getByText('timed_qual')).toBeVisible(); + await expect(row.getByText('From roster')).toBeVisible(); + + // ── It persisted on the Director: a reload resumes into the event with the round listed ───── + await page.reload(); + await expect(page.getByRole('button', { name: /Live control/ })).toBeVisible({ timeout: 15_000 }); + await openTab(page, 'Rounds & Heats'); + const rowAfter = page.getByRole('list').getByRole('listitem').filter({ hasText: LABEL }); + await expect(rowAfter).toBeVisible({ timeout: 15_000 }); + + // ── Edit the round's label ────────────────────────────────────────────────────────────────── + await rowAfter.getByRole('button', { name: 'Edit' }).click(); + const editForm = page.getByRole('form', { name: 'Edit round' }); + await expect(editForm).toBeVisible(); + const newLabel = `${LABEL}-v2`; + await editForm.getByLabel('Label').fill(newLabel); + await page.getByRole('button', { name: 'Save round' }).click(); + await expect( + page.getByRole('list').getByRole('listitem').filter({ hasText: newLabel }) + ).toBeVisible({ timeout: 15_000 }); + + // ── Remove the round ──────────────────────────────────────────────────────────────────────── + const editedRow = page.getByRole('list').getByRole('listitem').filter({ hasText: newLabel }); + await editedRow.getByRole('button', { name: 'Remove' }).click(); + await expect( + page.getByRole('list').getByRole('listitem').filter({ hasText: newLabel }) + ).toHaveCount(0, { timeout: 15_000 }); + + // ── Clean up: deselect the class so the shared Director's event goes back to empty. ───────── + await openTab(page, 'Classes'); + const cleanupBox = page + .getByRole('list', { name: 'Class directory' }) + .getByRole('listitem') + .filter({ hasText: 'Open Class' }) + .getByRole('checkbox', { name: 'Select Open Class' }); + if (await cleanupBox.isChecked()) { + await cleanupBox.uncheck(); + await page.getByRole('button', { name: 'Save classes' }).click(); + await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ + timeout: 15_000 + }); + } +}); From a088fdeb8c8b91a8da4a25664870d425ae009188 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 03:58:56 +0000 Subject: [PATCH 107/362] fix(test): type EventRounds mock call tuples so svelte-check passes createRound/deleteRound mocks had no params, so TS typed their .mock.calls tuples as [] -> tuple-index + possibly-undefined errors under tsconfig.test.json. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/tests/EventRounds.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 6863873..eb024ab 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -65,7 +65,7 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { win_condition: 'BestLap', seeding: 'FromRoster' }; - const createRoundImpl = vi.fn(async () => created); + const createRoundImpl = vi.fn(async (_b, _e, _req) => created); const { session } = makeTestSession({ ...impls, createRoundImpl, @@ -111,7 +111,7 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { format: 'single_elim', seeding: { FromRanking: { source_round: 'r1', top_n: 8 } } }; - const createRoundImpl = vi.fn(async () => updated); + const createRoundImpl = vi.fn(async (_b, _e, _req) => updated); const { session } = makeTestSession({ ...impls, createRoundImpl, event: EVENT }); render(EventRounds, { session }); @@ -157,7 +157,7 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { it('removes a round via deleteRound', async () => { const impls = baseImpls(); - const deleteRoundImpl = vi.fn(async () => ({ ...EVENT, rounds: [] })); + const deleteRoundImpl = vi.fn(async (_b, _e, _id) => ({ ...EVENT, rounds: [] })); const { session } = makeTestSession({ ...impls, deleteRoundImpl, event: EVENT }); render(EventRounds, { session }); From c517cc979eab7edcfd3b9955ace0f43ce131830f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 04:14:10 +0000 Subject: [PATCH 108/362] Race Slice 3a: per-event round-driven engine (FillRound) + Director-API e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the dormant format generators into a live, RD-driven, per-event flow. - `round_engine` (server): build a round's `Box` from its `RoundDef` + the event's `classes_membership` (the field), advance it off the log. `fill_round` reads the round's completed-and-scored heats from the log, calls `generator.next(&completed)`, and returns the next heat to schedule (or Complete / AlreadyScheduled — typed oks, not errors). Pure function of log + meta, deterministic on replay, exactly like `run_event`. - field = eligible classes' membership (pilot→competitor), or the top-N of a prior round's ranking for `SeedingRule::FromRanking` (the qualifying→bracket carry, reusing `advance_top_n` + `round_ranking`). - per-heat scoring reuses `engine::event::score_marshaled` over the heat's pass window (between its `Running` and the next terminal transition). - `Command::FillRound { round }` (control vocabulary) handled via a new event-aware `apply_command_in_event` (it needs the event meta, not just the log). On a `Scheduled` outcome it appends a `HeatScheduled` tagged with the round (and the class when single-class), lineup from the generator's plan, empty frequencies (Slice 4). Both control endpoints thread it. - Director-API e2e (`crates/app/tests/race_flow.rs`): on the `build_app` harness with the Mock timer + the per-event source bridge — create event → select class → set membership → define a `timed_qual` round → FillRound → drive Stage→Arm→Start→Finish→Score with the mock emitting laps → FillRound to Complete; then a second `FromRanking(top 2)` round to assert the bracket carry seeds from the qual ranking. Fast + deterministic-by-condition. - Regenerated TS bindings for the new `FillRound` command. The Heats UI (the free-text NewHeat replacement) is Slice 3b. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- Cargo.lock | 1 + bindings/Command.ts | 6 +- crates/app/Cargo.toml | 3 + crates/app/tests/race_flow.rs | 428 +++++++++++++++++ crates/server/src/control.rs | 19 + crates/server/src/control_handler.rs | 228 ++++++++- crates/server/src/lib.rs | 1 + crates/server/src/round_engine.rs | 689 +++++++++++++++++++++++++++ 8 files changed, 1369 insertions(+), 6 deletions(-) create mode 100644 crates/app/tests/race_flow.rs create mode 100644 crates/server/src/round_engine.rs diff --git a/Cargo.lock b/Cargo.lock index 92e7394..7223855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,7 @@ version = "0.1.0" dependencies = [ "axum", "gridfpv-adapters", + "gridfpv-engine", "gridfpv-events", "gridfpv-projection", "gridfpv-server", diff --git a/bindings/Command.ts b/bindings/Command.ts index a0dc151..b463fda 100644 --- a/bindings/Command.ts +++ b/bindings/Command.ts @@ -88,7 +88,11 @@ 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]>, } } | { "Register": { +frequencies?: Array<[CompetitorRef, number]>, } } | { "FillRound": { +/** + * The round to fill — one of the event's [`rounds`](crate::events::EventMeta::rounds). + */ +round: RoundId, } } | { "Register": { /** * The timing source the competitor belongs to. */ diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 0bed71e..0381261 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -42,6 +42,9 @@ tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } [dev-dependencies] serde_json.workspace = true gridfpv-adapters.workspace = true +# The race-flow e2e (`tests/race_flow.rs`) builds a `NewRoundReq` whose `win_condition` +# is the engine's `WinCondition` — the round-driven engine's scoring rule (Slice 3a). +gridfpv-engine.workspace = true # 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 diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs new file mode 100644 index 0000000..7dd4342 --- /dev/null +++ b/crates/app/tests/race_flow.rs @@ -0,0 +1,428 @@ +//! 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_events::{AdapterId, ClassId, Event, HeatId, RoundId}; +use gridfpv_server::app::AppState; +use gridfpv_server::classes::CreateClassRequest; +use gridfpv_server::control::{Command, CommandAck}; +use gridfpv_server::events::{EventRegistry, NewRoundReq, RoundDef, SeedingRule}; +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 }), + }, + ) + .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::Scored + | 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 → Arm → Start → (wait for laps) → Finish → Score. Returns the heat id. +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()), + }, + ) + .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; + control_ok(app, event, token, &Command::Arm { heat: heat.clone() }).await; + control_ok(app, event, token, &Command::Start { 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; + + control_ok(app, event, token, &Command::Finish { heat: heat.clone() }).await; + control_ok(app, event, token, &Command::Score { 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) Select the class for the event. + 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!({ "pilot_ids": 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: gridfpv_engine::scoring::WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + }, + ) + .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" + ); + + // --- 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(), + }, + ) + .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: "single_elim".into(), + params: BTreeMap::new(), + win_condition: gridfpv_engine::scoring::WinCondition::FirstToLaps { n: laps }, + seeding: SeedingRule::FromRanking { + source_round: qual.id.clone(), + top_n: 2, + }, + }, + ) + .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(), + }, + ) + .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)" + ); +} + +/// 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}"); +} + +/// `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/server/src/control.rs b/crates/server/src/control.rs index f737a66..b85fc0a 100644 --- a/crates/server/src/control.rs +++ b/crates/server/src/control.rs @@ -123,6 +123,22 @@ pub enum Command { frequencies: Vec<(CompetitorRef, u16)>, }, + /// **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, + }, + // --- 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 @@ -283,6 +299,9 @@ mod tests { (CompetitorRef("node-1".into()), 5695), ], }, + Command::FillRound { + round: RoundId("qualifying-r1-abc".into()), + }, Command::Register { adapter: AdapterId("rh".into()), competitor: CompetitorRef("node-2".into()), diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index a0804f4..9a64fcb 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -165,7 +165,9 @@ async fn control_post( // 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(&state, command))) + Ok(Json(apply_command_in_event( + ®istry, &event_id, &state, command, + ))) } /// `GET /control` — upgrade to the bidirectional control WebSocket (protocol.html §5). @@ -181,7 +183,7 @@ async fn control_ws( // 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, state))) + 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 @@ -192,18 +194,23 @@ async fn control_ws( /// frame is answered with a `CommandAck::failed(BadRequest)` rather than closing the /// socket, so one bad command does not drop the RD's control session. The loop ends when /// the client closes or the socket errors. -async fn run_control(mut socket: WebSocket, state: AppState) { +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(&state, command), + 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(&state, command), + Ok(command) => apply_command_in_event(®istry, &event_id, &state, command), Err(e) => CommandAck::failed(ProtocolError::new( ErrorCode::BadRequest, format!("malformed command: {e}"), @@ -223,6 +230,82 @@ async fn run_control(mut socket: WebSocket, state: AppState) { } } +/// 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 } => apply_fill_round(registry, event_id, state, round), + other => apply_command(state, other), + } +} + +/// Handle [`Command::FillRound`] (race redesign Slice 3a): build the round's generator from +/// the event meta + the log, append the next tagged [`Event::HeatScheduled`] the generator +/// emits, and ack. +/// +/// - A `Scheduled` outcome appends the heat tagged with `round` (and `class` when the round +/// is single-class), lineup from the generator's plan — then acks ok. +/// - A `Complete` or `AlreadyScheduled` outcome appends **nothing** and acks ok (a finished +/// round, or one whose outstanding heat must be scored first, are expected terminal/no-op +/// states — typed oks, not errors). +/// - A fill error (unknown round, empty field, unknown format) acks failed with a +/// [`ProtocolError`] — `UnknownScope` for a missing round, `BadRequest` otherwise. +fn apply_fill_round( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + round: gridfpv_events::RoundId, +) -> CommandAck { + use crate::round_engine::{self, FillError, FillOutcome}; + + let Some(meta) = registry.meta_of(event_id) else { + return CommandAck::failed(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + )); + }; + let events = match state.read() { + Ok((events, _cursor)) => events, + Err(err) => return CommandAck::failed(err), + }; + + match round_engine::fill_round(&meta, &round, &events) { + Ok(FillOutcome::Scheduled { heat, lineup }) => { + let class = round_engine::round_class(&meta, &round); + let event = Event::HeatScheduled { + heat, + lineup, + class, + round: Some(round), + // Frequencies are assigned in Slice 4 (the timer node-count cap lands there); + // a round-scheduled heat carries none for now (a sim race assigns no channels). + frequencies: Vec::new(), + }; + match state.append(event, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } + } + // Complete / AlreadyScheduled: nothing to append, a successful typed ack. + Ok(FillOutcome::Complete) | Ok(FillOutcome::AlreadyScheduled) => CommandAck::ok(), + Err(err @ FillError::UnknownRound(_)) => { + CommandAck::failed(ProtocolError::new(ErrorCode::UnknownScope, err.to_string())) + } + Err(err) => CommandAck::failed(ProtocolError::new(ErrorCode::BadRequest, err.to_string())), + } +} + /// 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. /// @@ -275,6 +358,15 @@ fn command_to_event(state: &AppState, command: Command) -> Result 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, @@ -645,4 +737,130 @@ mod tests { && *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::{CreateEventRequest, 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.clone()) + .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: WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + }, + ) + .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(), + }, + ); + 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()), + }, + ); + 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"); + } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index ff81792..454aaac 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -65,6 +65,7 @@ pub mod error; pub mod events; pub mod live_state; pub mod pilots; +pub mod round_engine; pub mod scope; pub mod snapshot; pub mod stream; diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs new file mode 100644 index 0000000..2c4adb1 --- /dev/null +++ b/crates/server/src/round_engine.rs @@ -0,0 +1,689 @@ +//! 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 `Scored`), 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 +//! `Score` (the existing FSM path appends `HeatStateChanged { Scored }`), 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 gridfpv_engine::event::score_marshaled; +use gridfpv_engine::format::{ + CompletedHeat, FormatConfig, FormatRegistry, GeneratorStep, RankEntry, advance_top_n, +}; +use gridfpv_engine::heat::{HeatState, heat_state}; +use gridfpv_engine::scoring::HeatResult; +use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, Pass, RoundId, SourceTime}; + +use crate::events::{EventMeta, RoundDef, SeedingRule}; + +/// 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, + }, + /// 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 + /// `Score`. 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` that does not exist in this + /// event. + UnknownSourceRound(String), +} + +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" + ) + } + } + } +} + +impl std::error::Error for FillError {} + +/// 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). +/// +/// - [`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. Each pilot id maps straight to a [`CompetitorRef`] of the same string +/// — the handle the lineup carries and the timer emits passes for. +/// - [`SeedingRule::FromRanking`]: the **top-N** of the source round's ranking (the +/// qualifying→bracket carry), reusing [`advance_top_n`] over the ranking +/// [`round_ranking`] computes from the source round's completed heats — exactly the +/// phase-2 seeding [`run_event`](gridfpv_engine::event::run_event) does. +fn round_field( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], +) -> Result, FillError> { + match &round.seeding { + SeedingRule::FromRoster => { + let mut field: Vec = Vec::new(); + for class in &round.classes { + if let Some(membership) = meta.classes_membership.iter().find(|m| &m.class == class) + { + for pilot in &membership.pilots { + let competitor = CompetitorRef(pilot.0.clone()); + if !field.contains(&competitor) { + field.push(competitor); + } + } + } + } + Ok(field) + } + SeedingRule::FromRanking { + source_round, + top_n, + } => { + let source = round_of(meta, source_round) + .map_err(|_| FillError::UnknownSourceRound(source_round.0.clone()))?; + let ranking = round_ranking(meta, source, events)?; + Ok(advance_top_n(&ranking, *top_n)) + } + } +} + +/// 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. +fn format_config(round: &RoundDef, field: Vec) -> FormatConfig { + let mut config = FormatConfig::new(field); + config.params = round.params.clone(); + config +} + +/// 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 [`Scored`](HeatState::Scored) (the FSM terminal the `Score` command +/// reaches). Each is scored via [`score_marshaled`] over the passes that heat produced (the +/// per-heat pass window, see [`heat_passes`]). 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 { + // The heats tagged with this round, in first-scheduled order (dedup repeated schedules + // of the same id — the latest lineup wins for the window scan, but order is by first + // appearance, matching the generator's emission order). + 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::Scored)) + .map(|heat| { + let passes = heat_passes(events, &heat); + let race_start = passes + .first() + .map(|p| p.at) + .unwrap_or_else(|| SourceTime::from_micros(0)); + // Score the corrected/marshaled view of this heat's passes under the round's + // win condition — the same scorer `run_event` uses, so the ranking the + // generator sees matches a wholesale run. + let pass_events: Vec = passes.into_iter().map(Event::Pass).collect(); + let result: HeatResult = score_marshaled(&pass_events, round.win_condition, race_start); + CompletedHeat::new(heat.0, result) + }) + .collect() +} + +/// 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> { + let field = round_field(meta, round, events)?; + 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)) +} + +/// 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, + round_id: &RoundId, + events: &[Event], +) -> Result { + let round = round_of(meta, round_id)?; + let field = round_field(meta, round, events)?; + if field.is_empty() { + return Err(FillError::EmptyField(round_id.0.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 Score 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. + let already: Vec = scheduled_round_heats(events, round_id); + let next = plans.into_iter().find(|p| !already.contains(&p.heat)); + match next { + Some(plan) => Ok(FillOutcome::Scheduled { + heat: plan.heat, + lineup: plan.lineup, + }), + // 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), + } +} + +/// 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) +} + +/// The lap-gate **passes a heat produced**, in log order (race redesign Slice 3a). +/// +/// The raw log does not tag passes with a heat, so we window them: a pass is attributed to +/// the heat that was **Running** when it was appended. We replay the log in order, tracking +/// the currently-running heat (set on a `Running` transition, cleared on any terminal / +/// off-ramp transition), and collect the passes that land while `heat` is the running one. +/// This is the same "passes are consumed only while the heat is Running" rule the heat FSM +/// states (race-engine.html §2), applied to isolate one heat's passes for scoring. +/// +/// Sufficient for the sequential mock-race flow the Slice-3 e2e drives (one heat runs at a +/// time); concurrent multi-class running and a precise per-heat pass log tag are a later +/// refinement (the seam is here, not in the scorer). +fn heat_passes(events: &[Event], heat: &HeatId) -> Vec { + let mut running: Option = None; + let mut out: Vec = Vec::new(); + for event in events { + match event { + Event::HeatStateChanged { + heat: h, + transition, + } => { + use gridfpv_events::HeatTransition as T; + match transition { + T::Running => running = Some(h.clone()), + // Any exit from Running (forward to Finished/Scored or an off-ramp) + // closes that heat's pass window. + T::Finished | T::Scored | T::Aborted | T::Restarted | T::Discarded + if running.as_ref() == Some(h) => + { + running = None; + } + _ => {} + } + } + Event::Pass(pass) if running.as_ref() == Some(heat) => { + out.push(pass.clone()); + } + _ => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{ClassMembership, EventMeta, RoundDef, SeedingRule}; + use crate::scope::{ClassId as ScopeClassId, EventId, PilotId}; + use gridfpv_engine::scoring::WinCondition; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, SourceTime}; + use std::collections::BTreeMap; + + const ADAPTER: &str = "mock"; + + 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| PilotId((*p).into())).collect(), + } + } + + 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, + } + } + + 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![], + } + } + + 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, + }) + } + + /// Drive one heat from Running to Scored 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::Scored)); + 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, &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, &RoundId("q1".into()), &[]), + Err(FillError::EmptyField(_)) + )); + } + + #[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 `round-1`. + let first = fill_round(&meta, &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 Scored 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, &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())); + } + + #[test] + fn bracket_round_seeds_from_a_prior_rounds_ranking() { + // A qual round, then a single_elim bracket seeded FromRanking(top 2) of the qual. + let qual = qual_round("q1", "open"); + let bracket = RoundDef { + id: RoundId("b1".into()), + label: "Bracket".into(), + classes: vec![ScopeClassId("open".into())], + format: "single_elim".into(), + params: BTreeMap::new(), + win_condition: WinCondition::FirstToLaps { n: 3 }, + seeding: SeedingRule::FromRanking { + source_round: RoundId("q1".into()), + top_n: 2, + }, + }; + let meta = meta_with( + vec![qual, bracket], + vec![member("open", &["A", "B", "C", "D"])], + ); + + // Run the qual heat to Scored: A fastest, then B, C, D. + let first = fill_round(&meta, &RoundId("q1".into()), &[]).unwrap(); + let qheat = match first { + FillOutcome::Scheduled { heat, .. } => heat.0, + other => panic!("expected scheduled, got {other:?}"), + }; + let mut log = vec![scheduled(&qheat, "q1", "open", &["A", "B", "C", "D"])]; + log.extend(run_heat_events( + &qheat, + vec![ + pass("A", 0, 0), + pass("B", 10, 0), + pass("C", 20, 0), + pass("D", 30, 0), + pass("A", 1_000_000, 1), + pass("B", 1_200_000, 1), + pass("C", 1_400_000, 1), + pass("D", 1_600_000, 1), + ], + )); + + // FillRound the bracket: the field is the top-2 of the qual ranking — A and B. + let outcome = fill_round(&meta, &RoundId("b1".into()), &log).unwrap(); + match outcome { + FillOutcome::Scheduled { lineup, .. } => { + assert_eq!( + lineup, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + "the bracket seeds from the qual ranking (top 2)" + ); + } + other => panic!("expected a scheduled bracket heat, got {other:?}"), + } + } + + #[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, &RoundId("q1".into()), &[]).unwrap(); + let twice = fill_round(&meta, &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, &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![], + }); + // 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); + } + + #[test] + fn heat_passes_windows_to_the_running_heat() { + // Two heats run back to back; each heat's passes attribute only to it. + let mut log = vec![ + scheduled("h1", "q1", "open", &["A"]), + scheduled("h2", "q1", "open", &["B"]), + ]; + log.extend(run_heat_events( + "h1", + vec![pass("A", 0, 0), pass("A", 1_000, 1)], + )); + log.extend(run_heat_events( + "h2", + vec![pass("B", 0, 0), pass("B", 2_000, 1)], + )); + + let h1 = heat_passes(&log, &HeatId("h1".into())); + let h2 = heat_passes(&log, &HeatId("h2".into())); + assert_eq!(h1.len(), 2); + assert!(h1.iter().all(|p| p.competitor == CompetitorRef("A".into()))); + assert_eq!(h2.len(), 2); + assert!(h2.iter().all(|p| p.competitor == CompetitorRef("B".into()))); + } +} From 8c56080cd5795b9c12333a416d5f7daf50df1324 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 04:18:41 +0000 Subject: [PATCH 109/362] Race Slice 3a (frontend): FillRound contract cases Add Slice-3a contract cases driving `Command::FillRound` over the real wire (`POST /events/{id}/control`): set up an event with the built-in `mgp-open` class + membership + a `timed_qual` round, then assert FillRound acks `{ ok: true }` and the round-tagged heat is snapshot-able; plus the empty-field ({ ok:false } BadRequest, HTTP 200), unknown-round ({ ok:false } UnknownScope), and RD-gated (401) cases. The `FillRound` variant flows into `@gridfpv/types` automatically from the regenerated repo-root `bindings/Command.ts`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/contract/events.contract.ts | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 879646e..9ca0aad 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -1182,3 +1182,123 @@ describe('race Slice 2a: rounds', () => { ]); }); }); + +/** + * Race redesign Slice 3a: the **round-driven engine** — `Command::FillRound`. Building the round's + * format generator from the eligible classes' membership (the field) and the round's completed heats + * read off the log, it schedules the **next** heat tagged with the round (and the class when + * single-class). A round whose generator has no more heats is **complete** — a successful ack, not an + * error. The Heats UI is Slice 3b; this guards the backend wire only. + * + * guards: + * - `FillRound` over `POST /events/{id}/control` (RD-gated) acks `{ ok: true }` and the + * round-scheduled heat becomes snapshot-able, tagged with the round + the single class, lineup = + * the class membership. + * - `FillRound` on a round with no membership (empty field) is a well-formed `{ ok: false }` + * (BadRequest), NOT an HTTP error; on an unknown round it is `{ ok: false, UnknownScope }`. + */ +describe('race Slice 3a: FillRound (round-driven engine)', () => { + /** `POST /events/{id}/control` a command with an optional token → raw status + parsed body. */ + async function control( + eventId: string, + command: Command, + token?: string + ): Promise<{ status: number; body: unknown }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${eventRoot(director.baseUrl, eventId)}/control`, { + method: 'POST', + headers, + body: JSON.stringify(command) + }); + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + return { status: res.status, body }; + } + + /** Create a pilot, returning its id. */ + async function makePilot(callsign: string): Promise { + const res = await fetch(`${director.baseUrl}/pilots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ callsign }) + }); + return ((await res.json()) as Pilot).id; + } + + /** Select `mgp-open`, set its membership to `pilotIds`, add a 1-round timed_qual; return ids. */ + async function setupQualRound( + eventName: string, + pilotIds: string[] + ): Promise<{ eventId: string; roundId: string }> { + const event = (await createEvent(eventName, TOKEN)).body as EventMeta; + await fetch(`${eventRoot(director.baseUrl, event.id)}/classes`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ ids: ['mgp-open'] }) + }); + await fetch(`${eventRoot(director.baseUrl, event.id)}/classes/mgp-open/membership`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ pilot_ids: pilotIds }) + }); + const created = await fetch(`${eventRoot(director.baseUrl, event.id)}/rounds`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ + label: 'Qualifying', + classes: ['mgp-open'], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap' + }) + }); + const round = (await created.json()) as RoundDef; + return { eventId: event.id, roundId: round.id }; + } + + it('FillRound acks ok and the round-scheduled heat is snapshot-able (tagged round + class)', async () => { + const a = await makePilot('Fill A'); + const b = await makePilot('Fill B'); + const { eventId, roundId } = await setupQualRound('FillRound Event', [a, b]); + + // FillRound draws the field from the class membership and schedules the first heat. + const ack = await control(eventId, { FillRound: { round: roundId } }, TOKEN); + expect(ack.status).toBe(200); + expect(ack.body).toEqual({ ok: true }); + + // The class snapshot now resolves (the round-tagged heat folded into the class scope). + const classSnap = await fetch( + `${eventRoot(director.baseUrl, eventId)}/snapshot/class/${eventId}/mgp-open` + ); + expect(classSnap.status).toBe(200); + }); + + it('FillRound on an empty-field round → CommandAck{ok:false} (BadRequest), HTTP 200', async () => { + // A round whose class has no membership has no field to schedule. + const { eventId, roundId } = await setupQualRound('FillRound Empty', []); + const ack = await control(eventId, { FillRound: { round: roundId } }, TOKEN); + expect(ack.status).toBe(200); // the failure rides in the ack body, not the HTTP status + const body = ack.body as { ok: boolean; error?: { code: string } }; + expect(body.ok).toBe(false); + expect(body.error?.code).toBe('BadRequest'); + }); + + it('FillRound on an unknown round → CommandAck{ok:false, UnknownScope}', async () => { + const event = (await createEvent('FillRound Unknown', TOKEN)).body as EventMeta; + const ack = await control(event.id, { FillRound: { round: 'no-such-round' } }, TOKEN); + const body = ack.body as { ok: boolean; error?: { code: string } }; + expect(body.ok).toBe(false); + expect(body.error?.code).toBe('UnknownScope'); + }); + + it('FillRound is RD-gated — no token → 401', async () => { + const { eventId, roundId } = await setupQualRound('FillRound Auth', []); + const res = await control(eventId, { FillRound: { round: roundId } }); + expect(res.status).toBe(401); + }); +}); From b50a30fb1641201c29a3140f1d65667e16d3162d Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 04:23:53 +0000 Subject: [PATCH 110/362] chore(eslint): ignore _-prefixed unused vars/args (standard convention) Honors the universal _-prefix 'intentionally unused' convention so typed mock-call-arg params (vi.fn call tuples) and similar don't trip no-unused-vars. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/eslint.config.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 9d4bb71..ada9058 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -33,5 +33,18 @@ export default ts.config( parser: ts.parser } } + }, + { + // Honor the `_`-prefix convention for intentionally-unused vars/args/catch bindings. + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ] + } } ); From 22f8d19101d6ab0dbe7d81e5d526cd944d93350c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 04:55:54 +0000 Subject: [PATCH 111/362] Race Slice 3b: Heats UI (Fill round + heats list + manual build; retire NewHeat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the "Heat building — Slice 3" placeholder in the Rounds & Heats stage with the real Heats UI, the Heats half of the stage: - Per round, a "Fill next heat" action sends `Command::FillRound { round }`; the per-event round engine appends the next format-generated `HeatScheduled`. The UI re-reads the heats list and surfaces "round complete / awaiting a score" when no new heat appeared. - The round's heats list groups the event's heats by round (id, resolved pilot callsigns, the class tag, a derived status, and the current marker). - Manual heat build replaces the retired free-text NewHeat: pick a round, select from its eligible class members (real roster pilots, no typed names), send a tagged `ScheduleHeat`. Server: a new `GET /events/{id}/heats` read folds the log into `HeatSummary`s (id, lineup, class/round tag, phase, is_current) — the round-tagged list the UI needs that `LiveRaceState` (current/on-deck only) does not carry. New `HeatSummary` binding. Session gains `fillRound` / `scheduleHeat` (control path) + `listHeats` (read); protocol-client gains `listHeats`. NewHeat.svelte + its test removed; LiveRaceControl no longer renders it; race.spec schedules its heat over the control path instead. Tests: server `heat_summaries` units; session fill/schedule/list units; EventRounds heats-list/fill/manual-build screen tests; a `heats.contract.ts` round-trip; an e2e that fills a round and builds a heat by hand. Gates green: build/check/lint/test/ contract + cargo xtask ci. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/HeatSummary.ts | 43 ++ crates/server/src/app.rs | 20 +- crates/server/src/live_state.rs | 160 ++++++- .../apps/rd-console/src/lib/session.svelte.ts | 52 ++ .../rd-console/src/screens/EventRounds.svelte | 452 +++++++++++++++++- .../src/screens/LiveRaceControl.svelte | 3 - .../rd-console/src/screens/NewHeat.svelte | 192 -------- .../apps/rd-console/tests/EventRounds.test.ts | 111 ++++- .../apps/rd-console/tests/NewHeat.test.ts | 54 --- .../rd-console/tests/session.svelte.test.ts | 57 +++ frontend/apps/rd-console/tests/support.ts | 9 +- frontend/contract/heats.contract.ts | 52 ++ frontend/e2e/race.spec.ts | 26 +- frontend/e2e/rounds.spec.ts | 99 ++++ .../packages/protocol-client/src/client.ts | 21 + .../packages/protocol-client/src/index.ts | 1 + frontend/packages/types/src/generated.ts | 1 + 17 files changed, 1062 insertions(+), 291 deletions(-) create mode 100644 bindings/HeatSummary.ts delete mode 100644 frontend/apps/rd-console/src/screens/NewHeat.svelte delete mode 100644 frontend/apps/rd-console/tests/NewHeat.test.ts create mode 100644 frontend/contract/heats.contract.ts diff --git a/bindings/HeatSummary.ts b/bindings/HeatSummary.ts new file mode 100644 index 0000000..259b909 --- /dev/null +++ b/bindings/HeatSummary.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 { 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 heat's folded loop phase (its derived status: scheduled / running / scored / …). + */ +phase: HeatPhase, +/** + * Whether this heat is the one currently on the timer (the live `current_heat`). + */ +is_current: boolean, }; diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index c096fa0..59f3305 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -96,7 +96,7 @@ use crate::events::{ SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::live_state; +use crate::live_state::{HeatSummary, heat_summaries, live_state}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::scope::{ClassId, EventId, PilotId}; use crate::snapshot::{ProjectionBody, Snapshot}; @@ -338,6 +338,9 @@ pub fn router(registry: EventRegistry) -> Router { "/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)) // 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)) @@ -936,6 +939,21 @@ async fn remove_round( 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))) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 9857eaa..708e7e9 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -41,7 +41,7 @@ use std::collections::BTreeMap; use gridfpv_engine::heat::{HeatState, heat_state}; -use gridfpv_events::{CompetitorRef, Event, HeatId, PilotId}; +use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, PilotId, RoundId}; use gridfpv_projection::{CompetitorKey, lap_list_marshaled, registrations}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -270,6 +270,101 @@ fn running_order(progress: &[PilotProgress]) -> Vec { 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 heat's folded loop phase (its derived status: scheduled / running / scored / …). + 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) = 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, + 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). +fn latest_schedule( + events: &[Event], + heat: &HeatId, +) -> (Vec, Option, Option) { + let mut out = (Vec::new(), None, None); + for event in events { + if let Event::HeatScheduled { + heat: h, + lineup, + class, + round, + .. + } = event + { + if h == heat { + out = (lineup.clone(), class.clone(), round.clone()); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -505,4 +600,67 @@ mod tests { ]; 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![], + } + } + + #[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::Scored), + scheduled_tagged("q-2", &["C", "D"], "open", "r1"), + scheduled("q-x", &["E"]), + ]; + // q-x was scheduled last, so it is the current heat; q-2 is not current. + 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::Scored); + 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()); + } } diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index e67624c..dfc4111 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -65,6 +65,7 @@ import { createRound, updateRound, deleteRound, + listHeats, PRACTICE_EVENT_ID } from '@gridfpv/protocol-client'; import type { ProtocolClient, ProtocolState, ConnectionStatus } from '@gridfpv/protocol-client'; @@ -84,6 +85,7 @@ import type { EventMeta, HeatId, HeatResult, + HeatSummary, LiveRaceState, Pilot, PilotId, @@ -255,6 +257,7 @@ export class Session { #createRoundImpl: typeof createRound; #updateRoundImpl: typeof updateRound; #deleteRoundImpl: typeof deleteRound; + #listHeatsImpl: typeof listHeats; constructor(opts?: { connectImpl?: typeof connect; @@ -286,6 +289,7 @@ export class Session { createRoundImpl?: typeof createRound; updateRoundImpl?: typeof updateRound; deleteRoundImpl?: typeof deleteRound; + listHeatsImpl?: typeof listHeats; baseUrl?: string; autoRestore?: boolean; }) { @@ -318,6 +322,7 @@ export class Session { this.#createRoundImpl = opts?.createRoundImpl ?? createRound; this.#updateRoundImpl = opts?.updateRoundImpl ?? updateRound; this.#deleteRoundImpl = opts?.deleteRoundImpl ?? deleteRound; + this.#listHeatsImpl = opts?.listHeatsImpl ?? listHeats; if (opts?.baseUrl) this.baseUrl = opts.baseUrl; if (opts?.autoRestore !== false) { const stored = loadStoredToken(); @@ -696,6 +701,53 @@ export class Session { return updated; } + // --- Heats (race redesign Slice 3b) ----------------------------------------------------------- + // The Heats half of the Rounds & Heats stage. `fillRound` and the tagged `scheduleHeat` are + // privileged **control commands** (they go through the same `send` path as every transition); + // `listHeats` is an open read of the round-tagged heats list the UI groups by round. + + /** + * Fill a round's **next heat** (`Command::FillRound`) — race redesign Slice 3b. Asks the per-event + * round engine to append the next format-generated `HeatScheduled` tagged with this round (and its + * class when single-class). A successful ack means *either* a heat was appended *or* the round is + * already complete / its outstanding heat must be scored first — the engine treats those as + * expected no-ops, so the caller re-reads {@link listHeats} to see whether a new heat appeared. + * Sent through the control path (full-trust first → lazy token); returns the raw {@link CommandAck} + * like {@link send}. + */ + fillRound(round: RoundId): Promise { + return this.send({ FillRound: { round } }); + } + + /** + * Schedule a heat by hand (`Command::ScheduleHeat`) — race redesign Slice 3b, the manual build that + * replaces the retired free-text NewHeat form. The lineup is real {@link CompetitorRef}s drawn from + * a round's eligible class members (no typed names), and the heat is **tagged** with the round and + * its class so it lands in that round's heats list. Sent through the control path; returns the raw + * {@link CommandAck}. + */ + scheduleHeat( + heat: HeatId, + lineup: CompetitorRef[], + tag: { class?: ClassId; round?: RoundId } = {} + ): Promise { + return this.send({ + ScheduleHeat: { heat, lineup, class: tag.class, round: tag.round } + }); + } + + /** + * List the current event's **scheduled heats** (`GET /events/{id}/heats`, open, no token) — race + * redesign Slice 3b. One {@link HeatSummary} per heat the log ever scheduled, with its round/class + * tag, lineup, derived phase, and current flag. The Heats UI filters this by round. No-op (resolves + * `[]`) when no event is selected; rejects on a transport/HTTP failure (the screen surfaces it). + */ + listHeats(): Promise { + const event = this.currentEvent; + if (!event) return Promise.resolve([]); + return this.#listHeatsImpl(this.baseUrl, event.id, { token: this.#token }); + } + /** * Bind a timing-source competitor to a pilot for lap attribution (`Command::Register`) — the * IRL **binding** step of the Roster stage. A timing source (RotorHazard node, sim player) reports diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index f6496bc..74cc501 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -23,7 +23,12 @@ import type { Class, ClassId, + CompetitorRef, + HeatPhase, + HeatSummary, NewRoundReq, + Pilot, + PilotId, RoundDef, RoundId, SeedingRule, @@ -59,8 +64,153 @@ .listFormats() .then((list) => (formats = list)) .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); + session + .listPilots() + .then((list) => (pilots = list)) + .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); + }); + + // --- The Heats half of the stage (race redesign Slice 3b) -------------------------------------- + // The round-tagged heats list (one entry per scheduled heat) and the pilot directory used to + // resolve a heat's `CompetitorRef` lineup to callsigns. The heats list is a read of + // `GET /events/{id}/heats`; it is re-fetched on enter, after each Fill round / manual build, and + // whenever the live state advances (so a heat's status follows it through Running → Scored). + + let pilots = $state([]); + let heats = $state([]); + let fillingRound = $state(undefined); + + async function refreshHeats() { + try { + heats = await session.listHeats(); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } + } + + // Initial load + re-load whenever the live state changes (a transition advances a heat's phase). + $effect(() => { + // Touch the live state so this effect re-runs on every stream update. + void session.liveState; + void refreshHeats(); }); + // A pilot id maps straight to a `CompetitorRef` of the same string (round_engine.rs); resolve a + // ref to its directory callsign, falling back to the bare ref for an unregistered/free-text one. + const pilotByRef = $derived(new Map(pilots.map((p) => [p.id, p] as const))); + const callsign = (ref: CompetitorRef): string => pilotByRef.get(ref)?.callsign ?? ref; + + const heatsByRound = (id: RoundId): HeatSummary[] => heats.filter((h) => h.round === id); + + function statusLabel(h: HeatSummary): string { + if (h.phase === 'Scored') return 'Scored'; + if (h.phase === 'Scheduled') return 'Scheduled'; + // Staged / Armed / Running / Finished all read as the heat being live/in-progress. + return h.phase === 'Finished' ? 'Finished' : 'Running'; + } + function statusKind(phase: HeatPhase): 'scheduled' | 'running' | 'scored' { + if (phase === 'Scored') return 'scored'; + if (phase === 'Scheduled') return 'scheduled'; + return 'running'; + } + + // Fill a round's next heat. The engine acks ok whether it appended a heat OR reported the round + // complete / its outstanding heat unscored, so compare the round's heat count before and after to + // tell the RD which happened. + async function fillRound(round: RoundDef) { + if (fillingRound) return; + fillingRound = round.id; + const before = heatsByRound(round.id).length; + try { + const ack = await session.fillRound(round.id); + if (!ack.ok) return; // The error banner / toast surfaces session.lastCommandError. + await refreshHeats(); + const after = heatsByRound(round.id).length; + if (after > before) toast.success(`Heat added to ${round.label}.`); + else toast.info(`${round.label}: no new heat — the round is complete or awaiting a score.`); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + fillingRound = undefined; + } + } + + // --- Manual heat build (replaces the retired NewHeat free-text form) --------------------------- + // Pick a round, then select from that round's **eligible class members** (real roster pilots, no + // typed names) → schedule a heat tagged with the round + its single class. The heat id is + // RD-entered (so a duplicate is caught by the ack); the lineup is the chosen pilots' refs. + + let buildOpen = $state(false); + let buildRound = $state(''); + let buildHeatId = $state(''); + let buildSelected = $state>(new Set()); + let building = $state(false); + + // The pilot ids eligible for the chosen round: the union of its eligible classes' membership. + const eligibleMembers = $derived(buildEligibleMembers(buildRound)); + function buildEligibleMembers(roundId: RoundId | ''): PilotId[] { + if (!roundId) return []; + const round = rounds.find((r) => r.id === roundId); + if (!round) return []; + const membership = session.currentEvent?.classes_membership ?? []; + const out: PilotId[] = []; + for (const cls of round.classes) { + const m = membership.find((mm) => mm.class === cls); + for (const pid of m?.pilots ?? []) if (!out.includes(pid)) out.push(pid); + } + return out; + } + + // The single class a round tags its heats with (one eligible class), else undefined (open round). + function roundClass(roundId: RoundId | ''): ClassId | undefined { + const round = rounds.find((r) => r.id === roundId); + return round && round.classes.length === 1 ? round.classes[0] : undefined; + } + + const canBuild = $derived( + buildRound !== '' && buildHeatId.trim().length > 0 && buildSelected.size > 0 + ); + + function openBuild() { + buildOpen = true; + buildRound = rounds[0]?.id ?? ''; + buildHeatId = ''; + buildSelected = new Set(); + } + function cancelBuild() { + buildOpen = false; + buildSelected = new Set(); + } + function toggleMember(pid: PilotId) { + const next = new Set(buildSelected); + if (next.has(pid)) next.delete(pid); + else next.add(pid); + buildSelected = next; + } + + async function submitBuild() { + if (building || !canBuild || buildRound === '') return; + building = true; + // Lineup in eligible-member order; a pilot id is its own CompetitorRef. + const lineup: CompetitorRef[] = eligibleMembers.filter((pid) => buildSelected.has(pid)); + try { + const ack = await session.scheduleHeat(buildHeatId.trim(), lineup, { + round: buildRound, + class: roundClass(buildRound) + }); + if (!ack.ok) return; // The toast/banner surfaces session.lastCommandError (e.g. a dup id). + await refreshHeats(); + toast.success('Heat scheduled.'); + buildOpen = false; + buildSelected = new Set(); + buildHeatId = ''; + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + building = false; + } + } + // --- The add/edit form ------------------------------------------------------------------------- // One form drives both add (no `editing`) and edit (an existing round id). The win condition and // seeding are kept as discriminator + a couple of numeric knobs, assembled into the wire shapes @@ -454,11 +604,137 @@ {/if} - -

      - Heat building — Slice 3. Once a round is defined, this is where its field gets - drawn into heats and slotted to channels. Not yet available. -

      + + {#snippet actions()} + + {/snippet} + + {#if rounds.length === 0} +

      + Add a round above first — heats are drawn from a round’s field. +

      + {:else} +
      + {#each rounds as round (round.id)} +
      +
      +
      + {round.label} + + {round.classes.length === eventClasses.length && eventClasses.length > 1 + ? 'All classes' + : round.classes.map(className).join(', ') || '—'} + +
      + +
      + + {#if heatsByRound(round.id).length === 0} +

      + No heats yet — Fill next heat to draw the first from this round’s field. +

      + {:else} +
        + {#each heatsByRound(round.id) as h (h.heat)} +
      1. +
        +
        + {h.heat} + {#if h.is_current}Current{/if} + {statusLabel(h)} +
        +
        + {#each h.lineup as ref, i (ref)} + + {callsign( + ref + )} + + {/each} + {#if h.lineup.length === 0}— no pilots —{/if} +
        +
        +
      2. + {/each} +
      + {/if} +
      + {/each} +
      + {/if} + + {#if buildOpen} +
      { + e.preventDefault(); + submitBuild(); + }} + > +

      Build a heat by hand

      +
      + + + + + + +
      + + +
      + {#each eligibleMembers as pid (pid)} + + {/each} +
      +
      + +
      + + +
      +
      + {/if}
      @@ -469,16 +745,12 @@ flex-direction: column; gap: var(--gf-space-4); } - .empty, - .placeholder { + .empty { margin: 0; font-size: var(--gf-font-size-md); color: var(--gf-text-secondary); line-height: 1.5; } - .placeholder strong { - color: var(--gf-text); - } .round-list { list-style: none; @@ -612,4 +884,164 @@ gap: var(--gf-space-2); padding-top: var(--gf-space-2); } + + /* ── Heats half of the stage ─────────────────────────────────────────────── */ + .heat-rounds { + display: flex; + flex-direction: column; + gap: var(--gf-space-4); + } + .heat-round { + display: flex; + flex-direction: column; + gap: var(--gf-space-2); + padding: var(--gf-space-3); + border: 1px solid var(--gf-border-subtle); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-sunken); + } + .heat-round-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gf-space-3); + } + .heat-round-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .empty.small { + font-size: var(--gf-font-size-sm); + } + .empty strong { + color: var(--gf-text); + } + .heat-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--gf-space-2); + } + .heat-row { + display: flex; + padding: var(--gf-space-3); + border: 1px solid var(--gf-border-subtle); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface); + } + .heat-row.current { + border-color: var(--gf-accent); + box-shadow: 0 0 0 1px var(--gf-accent); + } + .heat-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--gf-space-2); + } + .heat-head { + display: flex; + align-items: center; + gap: var(--gf-space-2); + flex-wrap: wrap; + } + .heat-id { + font-size: var(--gf-font-size-lg); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + font-family: var(--gf-font-mono, monospace); + } + .current-pill { + font-size: var(--gf-font-size-xs); + font-weight: var(--gf-font-weight-bold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + padding: 0.1rem var(--gf-space-2); + border-radius: var(--gf-radius-pill); + background: var(--gf-accent); + color: var(--gf-on-accent, #000); + } + .status-pill { + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + padding: 0.1rem var(--gf-space-2); + border-radius: var(--gf-radius-pill); + background: var(--gf-surface-sunken); + color: var(--gf-text-secondary); + border: 1px solid var(--gf-border-subtle); + } + .status-pill.running { + color: var(--gf-phase-running, var(--gf-accent)); + border-color: var(--gf-phase-running, var(--gf-accent)); + } + .status-pill.scored { + color: var(--gf-phase-scored, var(--gf-text-secondary)); + border-color: var(--gf-phase-scored, var(--gf-border)); + } + .lineup { + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .lineup-pilot { + display: inline-flex; + align-items: center; + gap: var(--gf-space-1); + font-size: var(--gf-font-size-md); + color: var(--gf-text); + padding: 0.1rem var(--gf-space-2); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-sunken); + } + .lineup-num { + display: inline-grid; + place-items: center; + width: 1.4rem; + height: 1.4rem; + border-radius: var(--gf-radius-xs); + background: var(--gf-surface); + color: var(--gf-text-muted); + font-size: var(--gf-font-size-xs); + font-variant-numeric: tabular-nums; + } + .lineup-empty { + font-size: var(--gf-font-size-sm); + color: var(--gf-text-muted); + } + .build-form { + margin-top: var(--gf-space-4); + padding-top: var(--gf-space-4); + border-top: 1px solid var(--gf-border-subtle); + display: flex; + flex-direction: column; + gap: var(--gf-space-4); + } + .member-picker { + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .member-chip { + display: inline-flex; + align-items: center; + gap: var(--gf-space-2); + padding: 0.3rem var(--gf-space-3); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-sunken); + font-size: var(--gf-font-size-md); + color: var(--gf-text); + cursor: pointer; + } + .member-chip input { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--gf-accent); + cursor: pointer; + } diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 5d74c03..3121dc6 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -24,7 +24,6 @@ import { useRaceClock } from '../lib/raceClock.svelte.js'; import ConfirmButton from '../lib/ConfirmButton.svelte'; import ErrorBanner from '../lib/ErrorBanner.svelte'; - import NewHeat from './NewHeat.svelte'; let { session, names = {} }: { session: Session; names?: Record } = $props(); @@ -116,8 +115,6 @@ session.clearCommandError()} /> {/if} - -
      Transitions
      diff --git a/frontend/apps/rd-console/src/screens/NewHeat.svelte b/frontend/apps/rd-console/src/screens/NewHeat.svelte deleted file mode 100644 index 7d26840..0000000 --- a/frontend/apps/rd-console/src/screens/NewHeat.svelte +++ /dev/null @@ -1,192 +0,0 @@ - - -
      -
      -

      New heat

      -

      - Type the heat id and the pilots flying it. Each name is registered on - sim and becomes the heat's lineup. -

      -
      - - - -
      - Pilots - {#each names as pilot, i (i)} -
      - - - -
      - {/each} -
      - -
      -
      - - {#if problems.length > 0} -
        - {#each problems as p (p)}
      • {p}
      • {/each} -
      - {/if} - -
      - -
      -
      - - diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index eb024ab..d5a8131 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, within } from '@testing-library/svelte'; import { fireEvent, waitFor } from '@testing-library/dom'; -import type { Class, EventMeta, RoundDef } from '@gridfpv/types'; +import type { Class, EventMeta, HeatSummary, Pilot, RoundDef } from '@gridfpv/types'; import EventRounds from '../src/screens/EventRounds.svelte'; import { makeTestSession } from './support.js'; const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; const SPEC: Class = { id: 'c2', name: 'Spec', source: 'Custom' }; +const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [], attributes: {} }; +const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [], attributes: {} }; + const QUAL: RoundDef = { id: 'r1', label: 'Qualifying R1', @@ -44,14 +47,17 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { const { session } = makeTestSession({ ...baseImpls(), event: EVENT }); render(EventRounds, { session }); - await screen.findByText('Qualifying R1'); + // The round label now appears both in the Rounds list and the Heats section, so scope the + // assertions to the Rounds card (the section whose heading is "Rounds"). + const roundsCard = screen.getByRole('heading', { name: 'Rounds' }).closest('section')!; + await within(roundsCard).findByText('Qualifying R1'); // Format and the resolved class name show; FromRoster summarises as "From roster". - expect(screen.getByText('timed_qual')).toBeInTheDocument(); - expect(screen.getByText('Open')).toBeInTheDocument(); - expect(screen.getByText('From roster')).toBeInTheDocument(); - expect(screen.getByText(/Timed · 120s/)).toBeInTheDocument(); + expect(within(roundsCard).getByText('timed_qual')).toBeInTheDocument(); + expect(within(roundsCard).getByText('Open')).toBeInTheDocument(); + expect(within(roundsCard).getByText('From roster')).toBeInTheDocument(); + expect(within(roundsCard).getByText(/Timed · 120s/)).toBeInTheDocument(); // The round index renders. - expect(screen.getByText('1')).toBeInTheDocument(); + expect(within(roundsCard).getByText('1')).toBeInTheDocument(); }); it('adds a round via createRound and reflects it immediately', async () => { @@ -98,8 +104,9 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { win_condition: 'BestLap', seeding: 'FromRoster' }); - // The new round appears in the list. - await screen.findByText('Open Practice'); + // The new round appears in the Rounds list (its label also seeds the Heats section). + const roundsCard = screen.getByRole('heading', { name: 'Rounds' }).closest('section')!; + await within(roundsCard).findByText('Open Practice'); }); it('reveals the FromRanking selector (source round + top N) and authors it', async () => { @@ -167,10 +174,88 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(deleteRoundImpl.mock.calls[0][2]).toBe('r1'); await waitFor(() => expect(session.currentEvent?.rounds).toEqual([])); }); +}); - it('shows the Slice 3 Heats placeholder', async () => { - const { session } = makeTestSession({ ...baseImpls(), event: EVENT }); +describe('EventRounds (Heats — fill round, heats list, manual build)', () => { + // An event whose Open class has two members, so a round can draw a field / a manual heat. + const EVENT_WITH_MEMBERS: EventMeta = { + ...EVENT, + roster: ['p1', 'p2'], + classes_membership: [{ class: 'c1', pilots: ['p1', 'p2'] }] + }; + + function heatsImpls(heats: HeatSummary[] = []) { + return { + ...baseImpls(), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl: vi.fn(async () => heats) + }; + } + + it("lists a round's heats with lineup callsigns, status, and the current marker", async () => { + const heat: HeatSummary = { + heat: 'q-1', + lineup: ['p1', 'p2'], + class: 'c1', + round: 'r1', + phase: 'Running', + is_current: true + }; + const { session } = makeTestSession({ ...heatsImpls([heat]), event: EVENT_WITH_MEMBERS }); + render(EventRounds, { session }); + + // The heat appears under its round with resolved callsigns, a Running status, and Current. + const heatRow = (await screen.findByText('q-1')).closest('.heat-row') as HTMLElement; + expect(within(heatRow).getByText('AceOne')).toBeInTheDocument(); + expect(within(heatRow).getByText('Bolt')).toBeInTheDocument(); + expect(within(heatRow).getByText('Running')).toBeInTheDocument(); + expect(within(heatRow).getByText('Current')).toBeInTheDocument(); + }); + + it('fills a round via FillRound and re-reads the heats list', async () => { + const impls = heatsImpls([]); + const newHeat: HeatSummary = { + heat: 'q-1', + lineup: ['p1', 'p2'], + class: 'c1', + round: 'r1', + phase: 'Scheduled', + is_current: true + }; + // First read (on mount) is empty; after the fill, the engine has appended a heat. + impls.listHeatsImpl.mockResolvedValueOnce([]).mockResolvedValue([newHeat]); + + const { session, sendSpy } = makeTestSession({ ...impls, event: EVENT_WITH_MEMBERS }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Fill next heat' })); + + // A FillRound command tagged with the round was sent. + await waitFor(() => expect(sendSpy).toHaveBeenCalled()); + expect(sendSpy.mock.calls[0][0]).toEqual({ FillRound: { round: 'r1' } }); + // The newly-scheduled heat shows up after the re-read. + await screen.findByText('q-1'); + }); + + it('builds a heat by hand from the round’s eligible members (tagged, no free text)', async () => { + const impls = heatsImpls([]); + const { session, sendSpy } = makeTestSession({ ...impls, event: EVENT_WITH_MEMBERS }); render(EventRounds, { session }); - await screen.findByText(/Heat building — Slice 3/i); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Build heat' })); + + // The round defaults to the first; type a heat id and pick both eligible members. + await fireEvent.input(await screen.findByLabelText('Build heat id'), { + target: { value: 'q-1' } + }); + await fireEvent.click(screen.getByLabelText('Select AceOne')); + await fireEvent.click(screen.getByLabelText('Select Bolt')); + await fireEvent.click(screen.getByRole('button', { name: 'Schedule heat' })); + + await waitFor(() => expect(sendSpy).toHaveBeenCalled()); + // A ScheduleHeat tagged with the round + its single class, lineup of the chosen pilot refs. + expect(sendSpy.mock.calls[0][0]).toEqual({ + ScheduleHeat: { heat: 'q-1', lineup: ['p1', 'p2'], class: 'c1', round: 'r1' } + }); }); }); diff --git a/frontend/apps/rd-console/tests/NewHeat.test.ts b/frontend/apps/rd-console/tests/NewHeat.test.ts deleted file mode 100644 index 39c250d..0000000 --- a/frontend/apps/rd-console/tests/NewHeat.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import NewHeat from '../src/screens/NewHeat.svelte'; -import { makeTestSession } from './support.js'; -import { failAck } from './fixtures.js'; - -describe('NewHeat', () => { - it('emits a Register per pilot then a ScheduleHeat from the typed field', async () => { - const { session, sendSpy } = makeTestSession(); - render(NewHeat, { session }); - - // Heat id seeds to 'q-1'; fill the two default pilot rows. - const pilots = screen.getAllByLabelText(/Pilot \d name/) as HTMLInputElement[]; - await fireEvent.input(pilots[0], { target: { value: 'Ace' } }); - await fireEvent.input(pilots[1], { target: { value: 'Bee' } }); - - await fireEvent.click(screen.getByRole('button', { name: 'Schedule heat' })); - - // The form sends the batch across awaits; wait for all three to land. - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(3)); - expect(sendSpy.mock.calls.map((c) => c[0])).toEqual([ - { Register: { adapter: 'sim', competitor: 'Ace', pilot: 'Ace' } }, - { Register: { adapter: 'sim', competitor: 'Bee', pilot: 'Bee' } }, - { ScheduleHeat: { heat: 'q-1', lineup: ['Ace', 'Bee'] } } - ]); - }); - - it('disables scheduling until at least two pilots are named', () => { - const { session } = makeTestSession(); - render(NewHeat, { session }); - expect( - (screen.getByRole('button', { name: 'Schedule heat' }) as HTMLButtonElement).disabled - ).toBe(true); - }); - - it('stops the batch on a failed ack (no ScheduleHeat after a failed Register)', async () => { - const { session, sendSpy } = makeTestSession({ ack: failAck }); - render(NewHeat, { session }); - const pilots = screen.getAllByLabelText(/Pilot \d name/) as HTMLInputElement[]; - await fireEvent.input(pilots[0], { target: { value: 'Ace' } }); - await fireEvent.input(pilots[1], { target: { value: 'Bee' } }); - - await fireEvent.click(screen.getByRole('button', { name: 'Schedule heat' })); - - // Only the first Register was attempted; the batch aborted before ScheduleHeat. Settle - // briefly to prove no further command is sent after the failed ack. - await waitFor(() => expect(sendSpy).toHaveBeenCalled()); - expect(sendSpy).toHaveBeenCalledTimes(1); - expect(sendSpy.mock.calls[0][0]).toEqual({ - Register: { adapter: 'sim', competitor: 'Ace', pilot: 'Ace' } - }); - }); -}); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 9b345f0..d9bda17 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -910,4 +910,61 @@ describe('Session', () => { expect(session.currentEvent?.classes).toEqual(['c1']); }); }); + + describe('heats (race redesign Slice 3b)', () => { + function heatSession(overrides?: { + sendCommand?: ReturnType; + listHeatsImpl?: ReturnType; + }) { + const { connect } = mockConnect(connecting); + const sendCommand = overrides?.sendCommand ?? vi.fn(async () => okAck); + const controlFactory = vi.fn(() => ({ baseUrl: 'http://d.local', sendCommand })); + const session = new Session({ + connectImpl: connect, + controlFactory, + baseUrl: 'http://d.local', + autoRestore: false, + listHeatsImpl: overrides?.listHeatsImpl + }); + return { session, sendCommand }; + } + + it('fillRound sends a FillRound command tagged with the round', async () => { + const sendCommand = vi.fn(async () => okAck); + const { session } = heatSession({ sendCommand }); + session.setToken('tok'); + session.selectEvent(PRACTICE); + const ack = await session.fillRound('r1'); + expect(ack).toEqual(okAck); + expect(sendCommand).toHaveBeenCalledWith({ FillRound: { round: 'r1' } }); + }); + + it('scheduleHeat sends a tagged ScheduleHeat with the lineup, class, and round', async () => { + const sendCommand = vi.fn(async () => okAck); + const { session } = heatSession({ sendCommand }); + session.setToken('tok'); + session.selectEvent(PRACTICE); + await session.scheduleHeat('q-1', ['p1', 'p2'], { class: 'c1', round: 'r1' }); + expect(sendCommand).toHaveBeenCalledWith({ + ScheduleHeat: { heat: 'q-1', lineup: ['p1', 'p2'], class: 'c1', round: 'r1' } + }); + }); + + it('listHeats reads the round-tagged heats open (no token), [] with no event', async () => { + const heats = [ + { heat: 'q-1', lineup: ['p1'], round: 'r1', phase: 'Scheduled', is_current: true } as const + ]; + const listHeatsImpl = vi.fn(async () => heats); + const { session } = heatSession({ listHeatsImpl }); + // No event selected → resolves [] without calling the impl. + await expect(session.listHeats()).resolves.toEqual([]); + expect(listHeatsImpl).not.toHaveBeenCalled(); + // Inside an event → reads GET /events/{id}/heats. + session.selectEvent(PRACTICE); + await expect(session.listHeats()).resolves.toEqual(heats); + expect(listHeatsImpl).toHaveBeenCalledWith('http://d.local', 'practice', { + token: undefined + }); + }); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 7f0b1ea..64bbebd 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -30,7 +30,8 @@ import type { listFormats, createRound, updateRound, - deleteRound + deleteRound, + listHeats } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -80,6 +81,8 @@ export interface TimerImpls { createRoundImpl?: typeof createRound; updateRoundImpl?: typeof updateRound; deleteRoundImpl?: typeof deleteRound; + /** The scheduled-heats read (race redesign Slice 3b) — backs the EventRounds Heats UI tests. */ + listHeatsImpl?: typeof listHeats; } export interface TestSession { @@ -152,7 +155,9 @@ export function makeTestSession( listFormatsImpl: opts?.listFormatsImpl, createRoundImpl: opts?.createRoundImpl, updateRoundImpl: opts?.updateRoundImpl, - deleteRoundImpl: opts?.deleteRoundImpl + deleteRoundImpl: opts?.deleteRoundImpl, + // Scheduled-heats read seam (race redesign Slice 3b): inert unless a test overrides it. + listHeatsImpl: opts?.listHeatsImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/contract/heats.contract.ts b/frontend/contract/heats.contract.ts new file mode 100644 index 0000000..5043701 --- /dev/null +++ b/frontend/contract/heats.contract.ts @@ -0,0 +1,52 @@ +/** + * Heats listing contract (race redesign Slice 3b): `GET /events/{id}/heats` + the real + * `listHeats` client helper. + * + * Schedules a **round/class-tagged** heat over the real control path (`Command::ScheduleHeat`), + * then reads it back through the real `@gridfpv/protocol-client`'s `listHeats`. Asserts the served + * `HeatSummary` round-trips the tag (round + class), the lineup, and a derived status — the exact + * shape the Heats UI groups by round. If the new endpoint, the binding, or the tag plumbing were + * wrong, the heat would not come back tagged. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { listHeats, PRACTICE_EVENT_ID } from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-heats-contract'; +const HEAT = 'q-1'; +const LINEUP = ['A', 'B']; +const ROUND = 'r1'; +const CLASS = 'open'; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector({ token: TOKEN }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('GET /heats serves the round-tagged scheduled heats', () => { + it('listHeats returns a tagged HeatSummary with lineup and a derived status', async () => { + // Schedule a heat tagged with a round + class over the real control path. + const ack = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: HEAT, lineup: LINEUP, class: CLASS, round: ROUND } + }); + expect(ack.ok).toBe(true); + + // Read the heats list back through the real client helper. + const heats = await listHeats(director.baseUrl, PRACTICE_EVENT_ID); + const summary = heats.find((h) => h.heat === HEAT); + expect(summary).toBeDefined(); + expect(summary!.lineup).toEqual(LINEUP); + expect(summary!.round).toBe(ROUND); + expect(summary!.class).toBe(CLASS); + // Freshly scheduled and on the timer: Scheduled phase, marked current. + expect(summary!.phase).toBe('Scheduled'); + expect(summary!.is_current).toBe(true); + }); +}); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 12c7efe..77b1859 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -28,7 +28,7 @@ import { expect, test } from './observability.js'; const PILOTS = ['Ace', 'Bee', 'Cee']; const HEAT_ID = 'q-1'; -test('RD drives a full basic sim race through the console UI', async ({ page }) => { +test('RD drives a full basic sim race through the console UI', async ({ page, director }) => { // ── Open the console: the home hub is the landing screen (#118) ────────────────────── await page.goto('/'); @@ -70,20 +70,16 @@ test('RD drives a full basic sim race through the console UI', async ({ page }) await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeHidden(); await expect(page.locator('.conn-label')).toHaveText('live', { timeout: 15_000 }); - // ── Define a heat with named pilots ────────────────────────────────────────────────── - const newHeat = page.getByRole('region', { name: 'New heat' }); - await newHeat.getByLabel('Heat id').fill(HEAT_ID); - // Two default pilot rows + one added → three named pilots. - await newHeat.getByRole('button', { name: 'Add pilot' }).click(); - for (let i = 0; i < PILOTS.length; i++) { - await newHeat.getByLabel(`Pilot ${i + 1} name`).fill(PILOTS[i]); - } - - // ── Schedule it: the Director is open (no token configured), so this control action goes - // straight through — NO token prompt appears (full-trust by default, #72) ────────────── - await newHeat.getByRole('button', { name: 'Schedule heat' }).click(); - // The lazy token prompt must NOT appear against an open Director. - await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + // ── Schedule a heat with named pilots over the real control path ────────────────────── + // The free-text NewHeat form is retired (race redesign Slice 3b); heats are built from real + // round/class members in the Rounds & Heats stage (covered by `rounds.spec.ts`). This race + // spec's focus is *running* a heat, so it schedules one straight over the open control path + // (the Director is booted with no token configured) — exactly what the Heats UI emits. + const ack = await page.request.post(`${director.baseUrl}/events/practice/control`, { + headers: { 'Content-Type': 'application/json' }, + data: { ScheduleHeat: { heat: HEAT_ID, lineup: PILOTS } } + }); + expect(ack.ok()).toBeTruthy(); // The heat lands on the timer: current heat + the lineup show, phase Scheduled. await expect(page.locator('.heat-id .value')).toHaveText(HEAT_ID); diff --git a/frontend/e2e/rounds.spec.ts b/frontend/e2e/rounds.spec.ts index d48a71b..1a60847 100644 --- a/frontend/e2e/rounds.spec.ts +++ b/frontend/e2e/rounds.spec.ts @@ -124,3 +124,102 @@ test('RD defines a round (class, format, seeding), it persists, then edits and r }); } }); + +/** + * The **Heats** half of the stage (race redesign Slice 3b) — the deliverable proof for the Heats UI. + * + * The prerequisites (a class with two members, a round) are set up over the real REST/control path + * (the same writes the Roster/Classes/Rounds stages emit), then the test drives the new UI: it + * clicks **Fill next heat** and asserts a heat appears in that round's list with the resolved + * pilot callsigns + the round tag, and then **builds a heat by hand** from the round's eligible + * members and asserts it lists too. Nothing about the Heats UI is mocked. + */ +test('RD fills a round and builds a heat by hand in the Heats UI', async ({ page, director }) => { + const base = director.baseUrl; + const ev = `${base}/events/practice`; + const json = { headers: { 'Content-Type': 'application/json' } }; + const SUFFIX = Date.now(); + const ACE = `E2E-Heat-Ace-${SUFFIX}`; + const BEE = `E2E-Heat-Bee-${SUFFIX}`; + const ROUND_LABEL = `E2E-HeatRound-${SUFFIX}`; + + // ── Set up over the real write paths: the Open Class selected, two rostered members, a round ── + const classes = (await (await page.request.get(`${base}/classes`)).json()) as Array<{ + id: string; + name: string; + }>; + const openClass = classes.find((c) => c.name === 'Open Class'); + expect(openClass, 'the built-in Open Class exists').toBeTruthy(); + const classId = openClass!.id; + + const mkPilot = async (callsign: string) => { + const p = (await ( + await page.request.post(`${base}/pilots`, { ...json, data: { callsign } }) + ).json()) as { id: string }; + return p.id; + }; + const aceId = await mkPilot(ACE); + const beeId = await mkPilot(BEE); + + // Select the class for the event, roster both pilots, and make them members of the class. + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [classId] } }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [aceId, beeId] } }); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [aceId, beeId] } + }); + // Add a single-class round (FromRoster) so FillRound can draw the two members. + const round = (await ( + await page.request.post(`${ev}/rounds`, { + ...json, + data: { + label: ROUND_LABEL, + classes: [classId], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap', + seeding: 'FromRoster' + } + }) + ).json()) as { id: string }; + expect(round.id).toBeTruthy(); + + // ── Into the Heats UI ──────────────────────────────────────────────────────────────────────── + await page.goto('/'); + await enterPractice(page); + await openTab(page, 'Rounds & Heats'); + const heatRound = page.getByRole('region', { name: `Heats for ${ROUND_LABEL}` }); + await expect(heatRound).toBeVisible({ timeout: 15_000 }); + + // ── Fill next heat → a heat appears in the round's list with the members' callsigns ─────────── + await heatRound.getByRole('button', { name: 'Fill next heat' }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + const filledRow = heatRound.locator('.heat-row').first(); + await expect(filledRow).toBeVisible({ timeout: 15_000 }); + await expect(filledRow.getByText(ACE)).toBeVisible(); + await expect(filledRow.getByText(BEE)).toBeVisible(); + + // ── Build a heat by hand from the round's eligible members ──────────────────────────────────── + const HAND_HEAT = `e2e-hand-${SUFFIX}`; + await page.getByRole('button', { name: '+ Build heat' }).click(); + const buildForm = page.getByRole('form', { name: 'Build heat' }); + await expect(buildForm).toBeVisible(); + await buildForm.getByLabel('Build round').selectOption({ label: ROUND_LABEL }); + await buildForm.getByLabel('Build heat id').fill(HAND_HEAT); + await buildForm.getByLabel(`Select ${ACE}`).check(); + await buildForm.getByLabel(`Select ${BEE}`).check(); + await page.getByRole('button', { name: 'Schedule heat' }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // The hand-built heat now lists under the round too. + await expect(heatRound.getByText(HAND_HEAT)).toBeVisible({ timeout: 15_000 }); + + // ── Clean up the shared Director's event back to empty. ─────────────────────────────────────── + await page.request.delete(`${ev}/rounds/${round.id}`); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [] } + }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [] } }); + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); +}); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index c0755f4..5c4bb59 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -29,6 +29,7 @@ import type { Cursor, EventId, EventMeta, + HeatSummary, NewRoundReq, Pilot, PilotId, @@ -902,6 +903,26 @@ export async function deleteRound( return (await resp.json()) as EventMeta; } +/** + * List an event's **scheduled heats** (`GET /events/{id}/heats`) — race redesign Slice 3b. A read + * (open, no token): the server folds the event log into one {@link 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. Resolves the list, or rejects on a non-2xx / transport failure; an unknown event is a 404. + */ +export async function listHeats( + baseUrl: string, + eventId: EventId, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}${eventRoot(eventId)}/heats`, { headers }); + if (!resp.ok) throw new Error(`GET /events/${eventId}/heats failed: HTTP ${resp.status}`); + return (await resp.json()) as HeatSummary[]; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index bc40ef9..1834ea9 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -41,6 +41,7 @@ export { createRound, updateRound, deleteRound, + listHeats, PRACTICE_EVENT_ID } from './client.js'; export type { diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 54652bb..411c69a 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -54,6 +54,7 @@ export type * from '@bindings/GateIndex'; export type * from '@bindings/HeatId'; export type * from '@bindings/HeatPhase'; export type * from '@bindings/HeatResult'; +export type * from '@bindings/HeatSummary'; export type * from '@bindings/HeatTransition'; export type * from '@bindings/Hello'; export type * from '@bindings/JoinTokenResponse'; From 98ceaafdbea05bde6432c8bed25fc230fe7b1991 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 05:19:45 +0000 Subject: [PATCH 112/362] =?UTF-8?q?Race=20Slice=204a:=20Channels=20?= =?UTF-8?q?=E2=80=94=20catalog=20+=20timer=20capability=20+=20assignment?= =?UTF-8?q?=20+=20tune=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the backend for timer-defined, dynamically-tuned video channels (generic, not RH-specific): - Channel catalog (`crates/server/src/channels.rs`): a standard FPV catalog (Raceband R1–R8, Fatshark/IRC F1–F8, Boscam A/B/E, clean digital DJI/HDZero) mapping band+channel → raw MHz; ts-rs exported (`ChannelCatalogEntry`) for the Slice-4b UI. `RACEBAND_MHZ` const + `is_known`. - Timer model (`crates/server/src/timers.rs`): additive `channel_capability` (Fixed{channels}|Flexible, default Flexible), `node_count` (default 8), and `available_channels` (raw MHz). The seeded Mock is flexible / 8 nodes / seeded from Raceband. Create/Update requests carry the new fields; all persisted and back-compatible (a pre-channel-model timers.json loads with defaults). - Per-heat assignment (`round_engine.rs`): `assign_frequencies` enforces the heat-size cap (lineup ≤ node count, typed `AssignError`) then allocates via the engine's `FrequencyPool`/`allocate` (deterministic first-fit, seed order), capped to the node count. `assign_for_event` resolves the event's effective primary timer. Wired into `FillRound` and `ScheduleHeat` (control_handler.rs): frequencies fill `HeatScheduled.frequencies`; an oversized lineup → typed 400. A pure-sim event (no resolvable timer / no available channels) assigns none. - Tune hook: `RhConnection::tune` + `RhConnections::tune` emit RotorHazard `set_frequency` per node over the existing persistent connection (best-effort, applied on the driver thread); the bridge calls it on the `Staged` transition from each heat's assigned `frequencies`. Mock = no-op (the sim flies on no real channels). RH = real (transport `set_frequency` added). - Bindings regenerated; tests: catalog sanity, capability/node-count persistence, assignment determinism + cap + too-few-channels, FillRound assigns frequencies + rejects an oversized heat, race_flow e2e carries frequencies + rejects an oversized heat. Frontend Timer fixtures updated for the new fields. The channels UI is Slice 4b. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ChannelCapability.ts | 24 ++ bindings/ChannelCatalogEntry.ts | 23 ++ bindings/CreateTimerRequest.ts | 18 +- bindings/Timer.ts | 23 +- bindings/UpdateTimerRequest.ts | 16 +- crates/adapters/src/rotorhazard/transport.rs | 11 + crates/app/src/source.rs | 72 +++++- crates/app/src/source/failover.rs | 3 + crates/app/src/source/rh_connections.rs | 15 ++ crates/app/src/source/rotorhazard.rs | 43 +++- crates/app/tests/race_flow.rs | 129 ++++++++++ crates/app/tests/rh_connect_live.rs | 7 + crates/server/src/app.rs | 10 + crates/server/src/channels.rs | 232 +++++++++++++++++ crates/server/src/control_handler.rs | 241 +++++++++++++++++- crates/server/src/lib.rs | 1 + crates/server/src/round_engine.rs | 227 +++++++++++++++++ crates/server/src/timers.rs | 219 +++++++++++++++- .../apps/rd-console/tests/EventTimers.test.ts | 15 +- .../apps/rd-console/tests/HomeHub.test.ts | 15 +- .../apps/rd-console/tests/TimersPage.test.ts | 15 +- .../rd-console/tests/session.svelte.test.ts | 15 +- 22 files changed, 1351 insertions(+), 23 deletions(-) create mode 100644 bindings/ChannelCapability.ts create mode 100644 bindings/ChannelCatalogEntry.ts create mode 100644 crates/server/src/channels.rs 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/CreateTimerRequest.ts b/bindings/CreateTimerRequest.ts index 25ac7ac..36c1791 100644 --- a/bindings/CreateTimerRequest.ts +++ b/bindings/CreateTimerRequest.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; import type { TimerKind } from "./TimerKind"; /** @@ -15,4 +16,19 @@ name: string, /** * The kind + config of the new timer. */ -kind: TimerKind, }; +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/Timer.ts b/bindings/Timer.ts index 5c57be2..b27ef35 100644 --- a/bindings/Timer.ts +++ b/bindings/Timer.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; import type { TimerId } from "./TimerId"; import type { TimerKind } from "./TimerKind"; import type { TimerStatus } from "./TimerStatus"; @@ -27,4 +28,24 @@ kind: TimerKind, /** * The derived usability of the timer (see [`TimerStatus`]). */ -status: 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, }; diff --git a/bindings/UpdateTimerRequest.ts b/bindings/UpdateTimerRequest.ts index d5f953f..97754e1 100644 --- a/bindings/UpdateTimerRequest.ts +++ b/bindings/UpdateTimerRequest.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; import type { TimerKind } from "./TimerKind"; /** @@ -16,4 +17,17 @@ name?: string, /** * A new kind + config, or `None` to leave it unchanged. */ -kind?: TimerKind, }; +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/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index 8384224..510007a 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -181,6 +181,17 @@ impl RotorHazardConnection { 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 }), + ) + } + /// 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![])) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 1b20f8e..09e9cf4 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -914,8 +914,28 @@ fn handle_transition( } } } - // Staged/Armed are pre-Running: nothing to cancel (the heat isn't emitting yet). - HeatTransition::Staged | HeatTransition::Armed => {} + // 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"). The Mock has no tune (the sim source ignores channels — no-op); + // each selected RotorHazard timer's live connection emits `set_frequency` per node. The plan + // is read from the heat's `HeatScheduled.frequencies` (empty ⇒ nothing to tune). + HeatTransition::Staged => { + #[cfg(feature = "live")] + { + let plan = tune_plan_of(state, &heat); + if !plan.is_empty() { + for timer_id in selected_rh_timers(registry, timers, event_id) { + connections.tune(event_id, &timer_id, plan.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 is pre-Running too: nothing to cancel (the heat isn't emitting yet). + HeatTransition::Armed => {} } } @@ -991,6 +1011,41 @@ fn lineup_of(state: &AppState, heat: &HeatId) -> Option> { 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, + .. + } = 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 +} + fn parse_env_u32(key: &str) -> Option { std::env::var(key).ok()?.trim().parse().ok() } @@ -1030,6 +1085,7 @@ mod tests { &UpdateTimerRequest { name: None, kind: Some(TimerKind::Mock { laps, lap_ms }), + ..Default::default() }, ) .unwrap(); @@ -1311,6 +1367,9 @@ mod tests { 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. @@ -1358,6 +1417,9 @@ mod tests { .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(); @@ -1556,6 +1618,9 @@ mod tests { .create(&CreateTimerRequest { name: name.into(), kind: TimerKind::Mock { laps, lap_ms }, + channel_capability: None, + node_count: None, + available_channels: None, }) .unwrap() .id @@ -1641,6 +1706,9 @@ mod tests { kind: TimerKind::Rotorhazard { url: "http://rh.local:5000".into(), }, + channel_capability: None, + node_count: None, + available_channels: None, }) .unwrap() .id; diff --git a/crates/app/src/source/failover.rs b/crates/app/src/source/failover.rs index 95f2025..e5a2c3d 100644 --- a/crates/app/src/source/failover.rs +++ b/crates/app/src/source/failover.rs @@ -89,6 +89,9 @@ mod tests { kind: TimerKind::Rotorhazard { url: "http://rh.local:5000".into(), }, + channel_capability: None, + node_count: None, + available_channels: None, }) .unwrap(); let practice = EventId("practice".into()); diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs index 262c4be..cbdd291 100644 --- a/crates/app/src/source/rh_connections.rs +++ b/crates/app/src/source/rh_connections.rs @@ -70,6 +70,21 @@ impl RhConnections { } } + /// **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 + } + } + /// 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) { diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index 815c309..8b02d24 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -66,6 +66,11 @@ const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(10); /// "dropped" without depending on transport-level disconnect callbacks. const IDLE_PROBE_INTERVAL: Duration = Duration::from_secs(5); +/// 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 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 { @@ -89,6 +94,11 @@ pub struct RhConnection { cancel: 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, /// 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. @@ -104,20 +114,33 @@ impl RhConnection { pub fn open(timer_id: TimerId, url: String, timers: TimerRegistry) -> Self { let cancel = Arc::new(AtomicBool::new(false)); let armed: Arc>> = Arc::new(Mutex::new(None)); + let tune: TuneSlot = Arc::new(Mutex::new(None)); let driver = { let cancel = cancel.clone(); let armed = armed.clone(); + let tune = tune.clone(); tokio::task::spawn_blocking(move || { - drive(url, timer_id, timers, cancel, armed); + drive(url, timer_id, timers, cancel, armed, tune); }) }; Self { cancel, armed, + tune, _driver: driver, } } + /// **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); + } + /// Arm a running heat onto this live connection: the driver stages the RH race and routes its /// translated passes (remapped onto `lineup`) into `sink`'s log. Replaces any previously armed /// heat (a newer running heat supersedes the prior one). @@ -177,12 +200,14 @@ fn remap(event: Event, lineup: &[CompetitorRef], adapter: &AdapterId) -> Option< /// 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, armed: Arc>>, + tune: TuneSlot, ) { let mut backoff = RECONNECT_BACKOFF_MIN; while !cancel.load(Ordering::Relaxed) { @@ -207,7 +232,7 @@ fn drive( backoff = RECONNECT_BACKOFF_MIN; // Maintain the live link until it drops or we are cancelled. - let dropped = maintain(&conn, &cancel, &armed); + let dropped = maintain(&conn, &cancel, &armed, &tune); // Stop any in-flight race and disconnect cleanly on the way out of this connection. conn.stop_race().ok(); @@ -241,6 +266,7 @@ fn maintain( conn: &RotorHazardConnection, cancel: &AtomicBool, armed: &Mutex>, + tune: &Mutex>>, ) -> bool { let mut last_activity = Instant::now(); let mut probed_since_activity = false; @@ -254,6 +280,19 @@ fn maintain( 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; + } + } + } + // Stage a freshly-armed heat once (reset RH to a clean READY state, then stage; RH // auto-starts). Done lazily here so staging happens on the driver thread, not the caller. let mut just_staged = false; diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index 7dd4342..c9617d9 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -54,6 +54,7 @@ fn fast_registry(laps: u32, lap_ms: u64) -> EventRegistry { &UpdateTimerRequest { name: None, kind: Some(TimerKind::Mock { laps, lap_ms }), + ..Default::default() }, ) .unwrap(); @@ -329,6 +330,26 @@ async fn round_driven_mock_race_flow_e2e() { "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( @@ -407,6 +428,114 @@ async fn round_driven_mock_race_flow_e2e() { ); } +/// 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/{}/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!({ "pilot_ids": 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: gridfpv_engine::scoring::WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + }, + ) + .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 }).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"); +} + /// 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; diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index d4bbfae..4360e3e 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -113,6 +113,9 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu 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. @@ -304,6 +307,7 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { laps: 600, lap_ms: 100, }), + ..Default::default() }, ) .expect("retune the Mock alternate to a long, brisk run"); @@ -314,6 +318,9 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { kind: TimerKind::Rotorhazard { url: rh.url().to_string(), }, + channel_capability: None, + node_count: None, + available_channels: None, }) .expect("create RH timer"); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 59f3305..e1b26d8 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1996,6 +1996,9 @@ mod tests { 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); @@ -2016,6 +2019,9 @@ mod tests { 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); @@ -2048,6 +2054,9 @@ mod tests { 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(); @@ -2101,6 +2110,7 @@ mod tests { laps: 9, lap_ms: 100, }), + ..Default::default() }; let response = router(registry.clone()) .oneshot( 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/control_handler.rs b/crates/server/src/control_handler.rs index 9a64fcb..3dc43c8 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -246,10 +246,98 @@ pub fn apply_command_in_event( ) -> CommandAck { match command { Command::FillRound { round } => apply_fill_round(registry, event_id, state, round), + // `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, + } => apply_schedule_heat( + registry, + event_id, + state, + heat, + lineup, + class, + round, + frequencies, + ), 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. +/// +/// 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)>, +) -> CommandAck { + use crate::round_engine; + + let Some(meta) = registry.meta_of(event_id) else { + return CommandAck::failed(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + )); + }; + + // 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, + }; + match state.append(event, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } +} + /// Handle [`Command::FillRound`] (race redesign Slice 3a): build the round's generator from /// the event meta + the log, append the next tagged [`Event::HeatScheduled`] the generator /// emits, and ack. @@ -283,14 +371,26 @@ fn apply_fill_round( match round_engine::fill_round(&meta, &round, &events) { Ok(FillOutcome::Scheduled { heat, lineup }) => { let class = round_engine::round_class(&meta, &round); + // Assign channels from the event's effective primary timer's available set (race + // redesign Slice 4a): the heat-size cap (lineup ≤ node count) is enforced here, and the + // engine's first-fit allocator fills `frequencies` in seed order. A pure-sim event (no + // resolvable timer / no available channels) assigns none — an un-channelled heat. + let frequencies = + 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(), + )); + } + }; let event = Event::HeatScheduled { heat, lineup, class, round: Some(round), - // Frequencies are assigned in Slice 4 (the timer node-count cap lands there); - // a round-scheduled heat carries none for now (a sim race assigns no channels). - frequencies: Vec::new(), + frequencies, }; match state.append(event, None) { Ok(_offset) => CommandAck::ok(), @@ -863,4 +963,139 @@ mod tests { 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::{CreateEventRequest, 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) + .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: WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + }, + ) + .unwrap(); + (registry, EventId(event.0.clone()), round.id) + } + + /// `FillRound` assigns channels from the event's selected timer onto the heat — the lineup gets + /// first-fit Raceband frequencies in seed order (race redesign Slice 4a). + #[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(), + }, + ); + 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"); + // Top two seeds get Raceband R1, R2 in order. + assert_eq!(freqs.len(), 2); + assert_eq!(freqs[0].1, 5658); + assert_eq!(freqs[1].1, 5695); + } + + /// `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 }); + 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"); + } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 454aaac..c55ebc9 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -58,6 +58,7 @@ pub mod app; pub mod auth; +pub mod channels; pub mod classes; pub mod control; pub mod control_handler; diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 2c4adb1..6d50c2f 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -48,10 +48,12 @@ use gridfpv_engine::format::{ CompletedHeat, FormatConfig, FormatRegistry, GeneratorStep, RankEntry, advance_top_n, }; use gridfpv_engine::heat::{HeatState, heat_state}; +use gridfpv_engine::schedule::{Frequency, FrequencyPool, allocate}; use gridfpv_engine::scoring::HeatResult; use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, Pass, RoundId, SourceTime}; use crate::events::{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 @@ -129,6 +131,129 @@ impl std::fmt::Display for FillError { 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) — 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. **First-fit allocation.** The available channels (raw MHz, in preference order, capped to the +/// node count) form a [`FrequencyPool`]; [`allocate`] hands each pilot the first free channel in +/// seed order (top seed → first channel). Too few channels for the lineup is +/// [`AssignError::TooFewChannels`]. +/// +/// Pure and deterministic: the same lineup + timer config always yields the same per-pilot +/// `(competitor, mhz)` assignment — the determinism `HeatScheduled.frequencies` and the e2e rely on. +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 pool is the available channels, in preference order, but never more than the timer has + // nodes for (a node can't run two channels). `FrequencyPool::new` de-duplicates. + let pool = FrequencyPool::new( + timer + .available_channels + .iter() + .take(nodes) + .copied() + .map(Frequency::new), + ); + match allocate(lineup, &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 @@ -397,6 +522,108 @@ mod tests { 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, + } + } + + fn lineup(names: &[&str]) -> Vec { + names.iter().map(|n| CompetitorRef((*n).into())).collect() + } + + #[test] + fn assign_is_first_fit_in_seed_order() { + // An 8-node Raceband timer assigns R1, R2, R3 to the top three seeds in order. + 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()), 5695), + (CompetitorRef("C".into()), 5732), + ] + ); + } + + #[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 + } + ); + } + fn meta_with(rounds: Vec, membership: Vec) -> EventMeta { EventMeta { id: EventId("e".into()), diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index 9380549..d2645de 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -42,6 +42,13 @@ 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"; @@ -81,6 +88,55 @@ pub enum TimerKind { }, } +/// 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). /// @@ -131,6 +187,29 @@ pub struct Timer { 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, +} + +/// 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 { @@ -155,6 +234,21 @@ pub struct CreateTimerRequest { 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). @@ -162,7 +256,7 @@ pub struct CreateTimerRequest { /// 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, PartialEq, Eq, Serialize, Deserialize, TS)] +#[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. @@ -173,6 +267,19 @@ pub struct UpdateTimerRequest { #[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 @@ -238,7 +345,9 @@ impl TimerRegistry { ) -> Result { let mut timers = BTreeMap::new(); - // Always seed the built-in Mock first. + // 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(), @@ -247,6 +356,9 @@ impl TimerRegistry { 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(), }; timers.insert(sim.id.clone(), sim); @@ -314,6 +426,9 @@ impl TimerRegistry { 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(), }; reg.timers.insert(id, timer.clone()); reg.persist()?; @@ -341,6 +456,15 @@ impl TimerRegistry { timer.kind = kind.clone(); timer.status = Timer::status_for(kind); } + 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) @@ -469,6 +593,9 @@ mod tests { laps: 3, lap_ms: 2000, }, + channel_capability: None, + node_count: None, + available_channels: None, } } @@ -478,6 +605,9 @@ mod tests { kind: TimerKind::Rotorhazard { url: url.to_string(), }, + channel_capability: None, + node_count: None, + available_channels: None, } } @@ -529,6 +659,7 @@ mod tests { laps: 9, lap_ms: 1000, }), + ..Default::default() }, ) .unwrap(); @@ -555,6 +686,7 @@ mod tests { laps: 1, lap_ms: 50, }), + ..Default::default() }, ) .unwrap(); @@ -597,6 +729,7 @@ mod tests { laps: 7, lap_ms: 1234, }), + ..Default::default() }, ) .unwrap(); @@ -673,6 +806,88 @@ mod tests { 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())); diff --git a/frontend/apps/rd-console/tests/EventTimers.test.ts b/frontend/apps/rd-console/tests/EventTimers.test.ts index bdf23f4..130644a 100644 --- a/frontend/apps/rd-console/tests/EventTimers.test.ts +++ b/frontend/apps/rd-console/tests/EventTimers.test.ts @@ -9,13 +9,19 @@ const MOCK: Timer = { id: 'mock', name: 'Mock', kind: { Mock: { laps: 3, lap_ms: 30000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; const RH: Timer = { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Configured' + status: 'Configured', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; /** A created event selecting only the Mock timer (so its checkbox seeds checked). */ @@ -97,7 +103,10 @@ describe('EventTimers (in-event CRUD + selection)', () => { id: 'fast-x', name: 'Fast', kind: { Mock: { laps: 5, lap_ms: 12000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; let calls = 0; const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts index 2b04886..8bca2d9 100644 --- a/frontend/apps/rd-console/tests/HomeHub.test.ts +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -35,12 +35,23 @@ const EVENTS: EventMeta[] = [ } ]; const TIMERS: Timer[] = [ - { id: 'mock', name: 'Mock', kind: { Mock: { laps: 3, lap_ms: 30000 } }, status: 'Ready' }, + { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] + }, { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Configured' + status: 'Configured', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] } ]; diff --git a/frontend/apps/rd-console/tests/TimersPage.test.ts b/frontend/apps/rd-console/tests/TimersPage.test.ts index 692fb18..106e084 100644 --- a/frontend/apps/rd-console/tests/TimersPage.test.ts +++ b/frontend/apps/rd-console/tests/TimersPage.test.ts @@ -12,13 +12,19 @@ const MOCK: Timer = { id: 'mock', name: 'Mock', kind: { Mock: { laps: 3, lap_ms: 30000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; const RH: Timer = { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Configured' + status: 'Configured', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; describe('TimersPage (app-level timer registry)', () => { @@ -42,7 +48,10 @@ describe('TimersPage (app-level timer registry)', () => { id: 'fast-x', name: 'Fast', kind: { Mock: { laps: 5, lap_ms: 12000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; let calls = 0; const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index d9bda17..483b153 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -468,7 +468,10 @@ describe('Session', () => { id: 'mock', name: 'Mock', kind: { Mock: { laps: 3, lap_ms: 30000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; function timerSession(overrides?: { @@ -507,7 +510,10 @@ describe('Session', () => { id: 'fast-x1', name: 'Fast', kind: { Mock: { laps: 5, lap_ms: 12000 } }, - status: 'Ready' + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; const createTimerImpl = vi.fn(async () => created); const session = timerSession({ createTimerImpl }); @@ -620,7 +626,10 @@ describe('Session', () => { id: 'rh-1', name: 'Track RH', kind: { Rotorhazard: { url: 'http://rh' } }, - status: 'Disconnected' + status: 'Disconnected', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] }; const listTimersImpl = vi.fn(async () => [rh, MOCK_CONNECTED]); // registry order differs const session = timerSession({ listTimersImpl }); From e6b8ec13018bfcb9f4f46a8d203eeded7b11b854 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 05:44:59 +0000 Subject: [PATCH 113/362] Race Slice 4b: Channels UI (timer channel config + per-heat channel labels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Channels UI atop Slice 4a's channel model: - GET /channels route returning the standard FPV channel catalog (ChannelCatalogEntry[]), an open read like /formats; plus the listChannels protocol-client helper + session.listChannels(). - HeatSummary now carries the per-heat frequencies assignment (from HeatScheduled.frequencies) so the Heats/Live UI can label them. - Timer registry editor (TimerManager): set a timer's capability (Fixed | Flexible), node count, and available channels — Flexible picks catalog channels (grouped by band) + custom raw-MHz entries; Fixed is limited to its built-in allowed set (no custom). Persisted via the existing create/update. - Per-heat channel display: EventRounds Heats list and LiveRaceControl resolve each pilot's assigned raw MHz back to a band+channel label (fallback raw "5800 MHz"); sim/free-text heats show "—". - Channel helpers (channels.ts): catalog grouping, label resolution, capability shaping, custom-MHz validation. Tests: channels.ts unit tests; EventRounds channel-label rendering (+ the "—" no-frequencies case); TimersPage catalog+custom picker and Fixed-allowed-set tests; a /channels contract case; an e2e that configures a timer's channels and asserts a filled heat shows labels. Adds frequencies to the bindings barrel + the ChannelCapability/ ChannelCatalogEntry re-exports Slice 4a missed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/HeatSummary.ts | 8 + crates/server/src/app.rs | 16 + crates/server/src/live_state.rs | 57 ++- frontend/apps/rd-console/src/lib/channels.ts | 91 ++++ .../apps/rd-console/src/lib/session.svelte.ts | 15 + .../rd-console/src/screens/EventRounds.svelte | 43 +- .../src/screens/LiveRaceControl.svelte | 104 ++++- .../src/screens/TimerManager.svelte | 390 +++++++++++++++++- .../apps/rd-console/tests/EventRounds.test.ts | 63 ++- .../apps/rd-console/tests/TimersPage.test.ts | 130 +++++- .../apps/rd-console/tests/channels.test.ts | 104 +++++ frontend/apps/rd-console/tests/support.ts | 5 + frontend/contract/channels.contract.ts | 57 +++ frontend/e2e/rounds.spec.ts | 121 ++++++ .../packages/protocol-client/src/client.ts | 20 + .../packages/protocol-client/src/index.ts | 1 + frontend/packages/types/src/generated.ts | 2 + 17 files changed, 1211 insertions(+), 16 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/channels.ts create mode 100644 frontend/apps/rd-console/tests/channels.test.ts create mode 100644 frontend/contract/channels.contract.ts diff --git a/bindings/HeatSummary.ts b/bindings/HeatSummary.ts index 259b909..d7c297f 100644 --- a/bindings/HeatSummary.ts +++ b/bindings/HeatSummary.ts @@ -33,6 +33,14 @@ class?: ClassId, * 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 heat's folded loop phase (its derived status: scheduled / running / scored / …). */ diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index e1b26d8..8e6f935 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -88,6 +88,7 @@ use serde::Deserialize; use tokio::sync::Notify; use crate::auth::{JoinTokenResponse, TokenStore}; +use crate::channels::ChannelCatalogEntry; use crate::classes::{Class, ClassError, ClassErrorKind, CreateClassRequest, UpdateClassRequest}; use crate::control_handler::ControlAuth; use crate::error::{ErrorCode, ProtocolError}; @@ -319,6 +320,11 @@ pub fn router(registry: EventRegistry) -> Router { // 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)) @@ -748,6 +754,16 @@ async fn list_formats() -> Json> { Json(formats) } +/// `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 diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 708e7e9..6fc8bb4 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -294,6 +294,13 @@ pub struct HeatSummary { #[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 heat's folded loop phase (its derived status: scheduled / running / scored / …). pub phase: HeatPhase, /// Whether this heat is the one currently on the timer (the live `current_heat`). @@ -324,7 +331,7 @@ pub fn heat_summaries(events: &[Event]) -> Vec { order .into_iter() .map(|heat| { - let (lineup, class, round) = latest_schedule(events, &heat); + let (lineup, class, round, frequencies) = latest_schedule(events, &heat); let phase = heat_state(events, &heat) .map(phase_of) .unwrap_or(HeatPhase::Scheduled); @@ -334,6 +341,7 @@ pub fn heat_summaries(events: &[Event]) -> Vec { lineup, class, round, + frequencies, phase, is_current, } @@ -343,22 +351,33 @@ pub fn heat_summaries(events: &[Event]) -> Vec { /// 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) { - let mut out = (Vec::new(), None, None); +) -> ( + Vec, + Option, + Option, + Vec<(CompetitorRef, u16)>, +) { + let mut out = (Vec::new(), None, None, Vec::new()); for event in events { if let Event::HeatScheduled { heat: h, lineup, class, round, - .. + frequencies, } = event { if h == heat { - out = (lineup.clone(), class.clone(), round.clone()); + out = ( + lineup.clone(), + class.clone(), + round.clone(), + frequencies.clone(), + ); } } } @@ -663,4 +682,32 @@ mod tests { fn heat_summaries_empty_log_is_empty() { assert!(heat_summaries(&[]).is_empty()); } + + #[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), + ], + }; + 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/frontend/apps/rd-console/src/lib/channels.ts b/frontend/apps/rd-console/src/lib/channels.ts new file mode 100644 index 0000000..24c96d5 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/channels.ts @@ -0,0 +1,91 @@ +/** + * Channel presentation + selection helpers (race redesign Slice 4b). + * + * Pure mappers shared by the timer channel-config picker (which offers the standard FPV catalog + * grouped by band, plus custom raw-MHz entries) and the per-heat channel display (which resolves a + * heat's assigned raw MHz back to a human band+channel label). No I/O — the session owns the + * `GET /channels` read; these just shape the catalog and the timer's capability for the UI. + */ +import type { ChannelCapability, ChannelCatalogEntry } from '@gridfpv/types'; + +/** A band and its catalog entries, in the catalog's stable channel order. */ +export interface ChannelBand { + /** The band name (e.g. `"Raceband"`, `"Fatshark"`, `"DJI"`). */ + band: string; + /** The band's entries (label + MHz), in catalog order. */ + entries: ChannelCatalogEntry[]; +} + +/** + * Group a flat catalog into bands, preserving the catalog's order both within and across bands + * (the server emits a stable, deterministic order — Raceband, Fatshark, Boscam A/B/E, DJI, HDZero). + */ +export function groupByBand(catalog: ChannelCatalogEntry[]): ChannelBand[] { + const bands: ChannelBand[] = []; + const byName = new Map(); + for (const entry of catalog) { + let band = byName.get(entry.band); + if (!band) { + band = { band: entry.band, entries: [] }; + byName.set(entry.band, band); + bands.push(band); + } + band.entries.push(entry); + } + return bands; +} + +/** + * The human label for a raw frequency, resolved through the catalog: `"Raceband R1"` (band + + * channel) when a catalog entry matches, else a bare `"5800 MHz"` fall-back for a custom/unknown + * channel. The **first** catalog entry whose MHz matches wins (the catalog is offered in a stable + * order, so Raceband is preferred over a coincident grid). + */ +export function channelLabel(mhz: number, catalog: ChannelCatalogEntry[]): string { + const hit = catalog.find((e) => e.mhz === mhz); + return hit ? `${hit.band} ${hit.channel}` : `${mhz} MHz`; +} + +/** The catalog entry an MHz resolves to (the first match), or `undefined` for a custom/unknown one. */ +export function catalogEntryFor( + mhz: number, + catalog: ChannelCatalogEntry[] +): ChannelCatalogEntry | undefined { + return catalog.find((e) => e.mhz === mhz); +} + +/** Whether a frequency is a catalog channel (vs. a custom raw-MHz entry the RD typed). */ +export function isCatalogChannel(mhz: number, catalog: ChannelCatalogEntry[]): boolean { + return catalog.some((e) => e.mhz === mhz); +} + +/** The discriminant tag of a capability (`'Fixed'` | `'Flexible'`). */ +export type CapabilityTag = 'Fixed' | 'Flexible'; + +export function capabilityTag(cap: ChannelCapability | undefined): CapabilityTag { + return cap && typeof cap === 'object' && 'Fixed' in cap ? 'Fixed' : 'Flexible'; +} + +/** A Fixed capability's allowed built-in set (its `channels`), or `[]` for a Flexible one. */ +export function fixedAllowed(cap: ChannelCapability | undefined): number[] { + return cap && typeof cap === 'object' && 'Fixed' in cap ? cap.Fixed.channels : []; +} + +/** + * The catalog a picker offers for a given capability: a **Fixed** timer is limited to its built-in + * allowed set (no custom), so only those catalog entries show; a **Flexible** timer offers the whole + * catalog (and may add custom raw MHz). Preserves catalog order. + */ +export function offeredCatalog( + cap: ChannelCapability | undefined, + catalog: ChannelCatalogEntry[] +): ChannelCatalogEntry[] { + if (capabilityTag(cap) === 'Flexible') return catalog; + const allowed = new Set(fixedAllowed(cap)); + return catalog.filter((e) => allowed.has(e.mhz)); +} + +/** A plausible 5.8 GHz centre frequency (the band the catalog lives in). Guards custom-MHz entry. */ +export function isPlausibleMhz(mhz: number): boolean { + return Number.isInteger(mhz) && mhz >= 5300 && mhz <= 6000; +} diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index dfc4111..882026c 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -62,6 +62,7 @@ import { setEventClasses, setClassMembership, listFormats, + listChannels, createRound, updateRound, deleteRound, @@ -73,6 +74,7 @@ import { createControlClient } from './control.js'; import type { ControlClient } from './control.js'; import type { AdapterId, + ChannelCatalogEntry, Class, ClassId, Command, @@ -254,6 +256,7 @@ export class Session { #setEventClassesImpl: typeof setEventClasses; #setClassMembershipImpl: typeof setClassMembership; #listFormatsImpl: typeof listFormats; + #listChannelsImpl: typeof listChannels; #createRoundImpl: typeof createRound; #updateRoundImpl: typeof updateRound; #deleteRoundImpl: typeof deleteRound; @@ -286,6 +289,7 @@ export class Session { setEventClassesImpl?: typeof setEventClasses; setClassMembershipImpl?: typeof setClassMembership; listFormatsImpl?: typeof listFormats; + listChannelsImpl?: typeof listChannels; createRoundImpl?: typeof createRound; updateRoundImpl?: typeof updateRound; deleteRoundImpl?: typeof deleteRound; @@ -319,6 +323,7 @@ export class Session { this.#setEventClassesImpl = opts?.setEventClassesImpl ?? setEventClasses; this.#setClassMembershipImpl = opts?.setClassMembershipImpl ?? setClassMembership; this.#listFormatsImpl = opts?.listFormatsImpl ?? listFormats; + this.#listChannelsImpl = opts?.listChannelsImpl ?? listChannels; this.#createRoundImpl = opts?.createRoundImpl ?? createRound; this.#updateRoundImpl = opts?.updateRoundImpl ?? updateRound; this.#deleteRoundImpl = opts?.deleteRoundImpl ?? deleteRound; @@ -646,6 +651,16 @@ export class Session { return this.#listFormatsImpl(this.baseUrl, { token: this.#token }); } + /** + * List the standard **FPV channel catalog** (`GET /channels`, open, no token) — race redesign + * Slice 4b. The band/channel ↔ raw-MHz vocabulary the server compiles in: the Channels UI offers + * it when picking a Flexible timer's available channels, and reads it back to label a heat's + * assigned frequencies. Rejects on a transport/HTTP failure. + */ + listChannels(): Promise { + return this.#listChannelsImpl(this.baseUrl, { token: this.#token }); + } + /** * Add a **round** to the current event (`POST /events/{id}/rounds`) — race redesign Slice 2b. * RD-gated; the round id is auto-generated server-side. No-op (resolves `undefined`) when no event diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 74cc501..65d118d 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -21,6 +21,7 @@ */ import { Button, Card, Field, Input, Select, toast } from '@gridfpv/components'; import type { + ChannelCatalogEntry, Class, ClassId, CompetitorRef, @@ -34,6 +35,7 @@ SeedingRule, WinCondition } from '@gridfpv/types'; + import { channelLabel } from '../lib/channels.js'; import type { Session } from '../lib/session.svelte.js'; let { session }: { session: Session } = $props(); @@ -42,6 +44,10 @@ // engine's `FormatRegistry::standard()`, via `GET /formats`). Both are open reads, loaded once. let classes = $state([]); let formats = $state([]); + // The standard channel catalog (race redesign Slice 4b): resolves a heat's assigned raw-MHz + // frequency back to a band+channel label. An open read, loaded once; an empty catalog degrades + // labels to raw "5800 MHz". + let catalog = $state([]); // The event's rounds, read straight off `currentEvent` (the session re-homes it after each write // so this stays live). Display order is definition order. @@ -68,6 +74,10 @@ .listPilots() .then((list) => (pilots = list)) .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); + session + .listChannels() + .then((list) => (catalog = list)) + .catch(() => (catalog = [])); }); // --- The Heats half of the stage (race redesign Slice 3b) -------------------------------------- @@ -102,6 +112,15 @@ const heatsByRound = (id: RoundId): HeatSummary[] => heats.filter((h) => h.round === id); + // A heat's per-pilot channel assignment, resolved to a band+channel label (race redesign Slice + // 4b). `HeatScheduled.frequencies` pairs each ref with a raw MHz; map ref → label so the lineup + // can show it. A sim/free-text heat carries no frequencies, so a ref resolves to `undefined` ("—"). + function channelByRef(h: HeatSummary): Map { + const map = new Map(); + for (const [ref, mhz] of h.frequencies ?? []) map.set(ref, channelLabel(mhz, catalog)); + return map; + } + function statusLabel(h: HeatSummary): string { if (h.phase === 'Scored') return 'Scored'; if (h.phase === 'Scheduled') return 'Scheduled'; @@ -649,6 +668,7 @@ {:else}
        {#each heatsByRound(round.id) as h (h.heat)} + {@const channels = channelByRef(h)}
      1. @@ -659,9 +679,11 @@
        {#each h.lineup as ref, i (ref)} - {callsign( - ref - )} + + {callsign(ref)} + + {channels.get(ref) ?? '—'} + {/each} {#if h.lineup.length === 0}— no pilots —(live?.current_heat); const primary = $derived(primaryAction(phase)); + // ── Per-heat channels (race redesign Slice 4b) ─────────────────────────────── + // The live stream carries only `LiveRaceState` (no frequencies), so resolve the current heat's + // channel assignment by joining `current_heat` against the heats list (which carries the + // `HeatScheduled.frequencies`), labelled through the standard catalog. Both are open reads, + // re-fetched whenever the live state advances (so a freshly-staged heat's channels appear). + let catalog = $state([]); + let heats = $state([]); + $effect(() => { + session + .listChannels() + .then((c) => (catalog = c)) + .catch(() => (catalog = [])); + }); + $effect(() => { + void session.liveState; + session + .listHeats() + .then((h) => (heats = h)) + .catch(() => (heats = [])); + }); + + // The current heat's ref → channel-label map (race redesign Slice 4b). Empty for a sim/free-text + // heat (no frequencies assigned), in which case the channels panel shows "—". + const currentChannels = $derived.by(() => { + const summary = heats.find((h) => h.heat === heat); + const map = new Map(); + for (const [ref, mhz] of summary?.frequencies ?? []) map.set(ref, channelLabel(mhz, catalog)); + return map; + }); + const lineup = $derived(live?.active_pilots ?? []); + const hasChannels = $derived(currentChannels.size > 0); + // ── Race clock (#62) ──────────────────────────────────────────────────────────────── // The phase-driven elapsed clock now lives in the shared `useRaceClock` helper so the // persistent ContextHeader (#85) and this screen drive the *same* clock from one place @@ -133,6 +173,24 @@
        + {#if heat && lineup.length > 0} + +
          + {#each lineup as ref (ref)} +
        • + {names[ref] ?? ref} + + {currentChannels.get(ref) ?? '—'} + +
        • + {/each} +
        + {#if !hasChannels} +

        No channels assigned (a sim heat tunes none).

        + {/if} +
        + {/if} +
        {#if live} @@ -287,6 +345,50 @@ gap: var(--gf-space-3); } + .channels { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: var(--gf-space-3); + } + .channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gf-space-3); + padding: var(--gf-space-2) var(--gf-space-3); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + background: var(--gf-surface); + } + .channel-pilot { + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-semibold); + letter-spacing: var(--gf-tracking-tight); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .channel-label { + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-bold); + color: var(--gf-accent); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .channel-label.none { + color: var(--gf-text-faint); + font-weight: var(--gf-font-weight-regular); + } + .channels-note { + margin: var(--gf-space-3) 0 0; + color: var(--gf-text-muted); + font-size: var(--gf-font-size-sm); + } + .panels { display: grid; grid-template-columns: 1fr 1fr; diff --git a/frontend/apps/rd-console/src/screens/TimerManager.svelte b/frontend/apps/rd-console/src/screens/TimerManager.svelte index e892b92..545cd4e 100644 --- a/frontend/apps/rd-console/src/screens/TimerManager.svelte +++ b/frontend/apps/rd-console/src/screens/TimerManager.svelte @@ -29,7 +29,14 @@ toast } from '@gridfpv/components'; import type { Snippet } from 'svelte'; - import type { CreateTimerRequest, Timer, TimerKind, UpdateTimerRequest } from '@gridfpv/types'; + import type { + ChannelCapability, + ChannelCatalogEntry, + CreateTimerRequest, + Timer, + TimerKind, + UpdateTimerRequest + } from '@gridfpv/types'; import type { Session } from '../lib/session.svelte.js'; import { DEFAULT_MOCK_LAPS, @@ -41,6 +48,14 @@ kindTone, type TimerKindTag } from '../lib/timers.js'; + import { + capabilityTag, + channelLabel, + fixedAllowed, + groupByBand, + isPlausibleMhz, + type CapabilityTag + } from '../lib/channels.js'; let { session, @@ -89,6 +104,28 @@ void load(); }); + // ── The standard channel catalog (race redesign Slice 4b) ─────────────────── + // Loaded once from `GET /channels` (open read); the channel picker offers it grouped by band, and + // the row summary resolves a timer's available raw-MHz set back to band+channel labels. A failed + // load degrades to an empty catalog — custom raw-MHz entry still works, labels fall back to MHz. + let catalog = $state([]); + const bands = $derived(groupByBand(catalog)); + $effect(() => { + session + .listChannels() + .then((c) => (catalog = c)) + .catch(() => (catalog = [])); + }); + + /** A timer's available channels, summarised as band+channel labels (e.g. "Raceband R1, F4 …"). */ + function channelSummary(timer: Timer): string { + const list = timer.available_channels ?? []; + if (list.length === 0) return 'No channels available'; + const labels = list.map((mhz) => channelLabel(mhz, catalog)); + const shown = labels.slice(0, 4).join(', '); + return labels.length > 4 ? `${shown} +${labels.length - 4} more` : shown; + } + /** * The rows to render: the loaded registry, but with each timer's **status** overlaid from the * session's live-polled {@link Session.timers} when available (#73, Slice 2b). The load fetches @@ -125,6 +162,41 @@ let saving = $state(false); let formError = $state(undefined); + // ── Channel config (race redesign Slice 4b) ────────────────────────────────── + // The capability (Fixed | Flexible), node count, and the chosen available channels. The chosen set + // is held as a `Set` of raw MHz (catalog picks + custom entries); for a Fixed timer it is + // limited to the timer's built-in allowed set (`Fixed.channels`); a Flexible timer can add custom. + const DEFAULT_NODE_COUNT = 8; + let formCapability = $state('Flexible'); + let formNodeCount = $state(String(DEFAULT_NODE_COUNT)); + let formChannels = $state>(new Set()); + // A Fixed timer's built-in allowed set (its physically-supported channels); the picker offers + // exactly these, and `formChannels` is the subset the RD makes available. Empty ⇒ all catalog. + let formFixedAllowed = $state([]); + let formCustomMhz = $state(''); + + /** The catalog entries the picker offers: a Fixed timer's allowed set, else the whole catalog. */ + const offeredBands = $derived.by(() => { + if (formCapability === 'Flexible' || formFixedAllowed.length === 0) return bands; + const allowed = new Set(formFixedAllowed); + return bands + .map((b) => ({ band: b.band, entries: b.entries.filter((e) => allowed.has(e.mhz)) })) + .filter((b) => b.entries.length > 0); + }); + + /** Custom (non-catalog) MHz the RD has added — only meaningful for a Flexible timer. */ + const customChannels = $derived( + [...formChannels].filter((mhz) => !catalog.some((e) => e.mhz === mhz)).sort((a, b) => a - b) + ); + + function resetChannelForm(timer?: Timer) { + formCapability = capabilityTag(timer?.channel_capability); + formNodeCount = String(timer?.node_count ?? DEFAULT_NODE_COUNT); + formChannels = new Set(timer?.available_channels ?? []); + formFixedAllowed = fixedAllowed(timer?.channel_capability); + formCustomMhz = ''; + } + export function openAdd() { editing = undefined; formName = ''; @@ -132,6 +204,7 @@ formLaps = String(DEFAULT_MOCK_LAPS); formLapMs = String(DEFAULT_MOCK_LAP_MS); formUrl = ''; + resetChannelForm(); formError = undefined; formOpen = true; } @@ -149,10 +222,75 @@ formLapMs = String(DEFAULT_MOCK_LAP_MS); formUrl = timer.kind.Rotorhazard.url; } + resetChannelForm(timer); formError = undefined; formOpen = true; } + /** Toggle a catalog channel in/out of the available set. */ + function toggleChannel(mhz: number) { + const next = new Set(formChannels); + if (next.has(mhz)) next.delete(mhz); + else next.add(mhz); + formChannels = next; + } + + /** Add a custom raw-MHz channel (Flexible only). Validates a plausible 5.8 GHz centre. */ + function addCustomMhz() { + // The number Input may bind a number or a string depending on the value typed; coerce safely. + const mhz = Math.round(Number(String(formCustomMhz).trim())); + if (!isPlausibleMhz(mhz)) { + formError = 'Enter a channel frequency between 5300 and 6000 MHz.'; + return; + } + formError = undefined; + formChannels = new Set(formChannels).add(mhz); + formCustomMhz = ''; + } + + function removeChannel(mhz: number) { + const next = new Set(formChannels); + next.delete(mhz); + formChannels = next; + } + + /** Enter in the custom-MHz field adds the channel rather than submitting the dialog. */ + function onCustomKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomMhz(); + } + } + + /** + * Build the typed {@link ChannelCapability} the request carries from the form. A **Flexible** + * timer is `"Flexible"` (any channel). A **Fixed** timer keeps its built-in allowed set when it + * had one, else its allowed set is exactly the catalog channels the RD chose (no custom). + */ + function buildCapability(): ChannelCapability { + if (formCapability === 'Flexible') return 'Flexible'; + // A Fixed timer's allowed set: its pre-existing built-in set, or — if none was declared — the + // catalog channels the RD picked (custom raw MHz are not allowed on a Fixed timer). + const allowed = + formFixedAllowed.length > 0 + ? formFixedAllowed + : [...formChannels].filter((mhz) => catalog.some((e) => e.mhz === mhz)); + return { Fixed: { channels: allowed } }; + } + + /** The available channels the request carries, ordered by the catalog then custom ascending. */ + function buildAvailable(): number[] { + const order = new Map(catalog.map((e, i) => [e.mhz, i])); + return [...formChannels].sort((a, b) => { + const ai = order.get(a); + const bi = order.get(b); + if (ai !== undefined && bi !== undefined) return ai - bi; + if (ai !== undefined) return -1; // catalog channels before custom + if (bi !== undefined) return 1; + return a - b; // both custom: ascending MHz + }); + } + /** Build the typed `TimerKind` from the form, or a problem string if a field is invalid. */ function buildKind(): { kind: TimerKind } | { problem: string } { if (formKind === 'Mock') { @@ -181,11 +319,25 @@ formError = built.problem; return; } + const nodeCount = Number(formNodeCount); + if (!Number.isFinite(nodeCount) || nodeCount < 1) { + formError = 'Node count must be at least 1.'; + return; + } + const channel_capability = buildCapability(); + const available_channels = buildAvailable(); + const node_count = Math.round(nodeCount); saving = true; formError = undefined; try { if (editing) { - const req: UpdateTimerRequest = { name, kind: built.kind }; + const req: UpdateTimerRequest = { + name, + kind: built.kind, + channel_capability, + node_count, + available_channels + }; const updated = await session.updateTimer(editing.id, req); if (!updated) { formError = 'A control token is required to edit a timer.'; @@ -193,7 +345,13 @@ } toast.success(`Updated “${updated.name}”.`); } else { - const req: CreateTimerRequest = { name, kind: built.kind }; + const req: CreateTimerRequest = { + name, + kind: built.kind, + channel_capability, + node_count, + available_channels + }; const created = await session.createTimer(req); if (!created) { formError = 'A control token is required to add a timer.'; @@ -268,6 +426,15 @@ >{/if}
        {kindSummary(timer.kind)} +
        + {capabilityTag(timer.channel_capability)} + + {timer.node_count ?? DEFAULT_NODE_COUNT} nodes + + {channelSummary(timer)} +
        @@ -335,6 +502,95 @@ /> {/if} + + +
        +
        + + + + + + +
        + + +
        + {#if offeredBands.length === 0} +

        + {catalog.length === 0 + ? 'Channel catalog unavailable.' + : 'This Fixed timer has no built-in channels to offer.'} +

        + {:else} + {#each offeredBands as group (group.band)} +
        + {group.band} +
        + {#each group.entries as entry (entry.mhz + '-' + entry.channel)} + + {/each} +
        +
        + {/each} + {/if} +
        +
        + + {#if formCapability === 'Flexible'} + +
        + + +
        +
        + {#if customChannels.length > 0} +
        + {#each customChannels as mhz (mhz)} + + {mhz} MHz + + + {/each} +
        + {/if} + {/if} {#snippet footer()} @@ -475,4 +731,132 @@ grid-template-columns: 1fr 1fr; gap: var(--gf-space-3); } + + /* ── Channel config (Slice 4b) ───────────────────────────────────────────── */ + .timer-channels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--gf-space-2); + margin-top: 2px; + font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + } + .timer-channels .nodes { + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + } + .channel-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .form-rule { + border: none; + border-top: 1px solid var(--gf-border-subtle); + margin: var(--gf-space-2) 0 0; + } + + .channel-picker { + display: flex; + flex-direction: column; + gap: var(--gf-space-3); + max-height: 18rem; + overflow-y: auto; + padding: var(--gf-space-2); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + background: var(--gf-surface-alt); + } + .channel-empty { + margin: 0; + color: var(--gf-text-muted); + font-size: var(--gf-font-size-sm); + } + .channel-band { + display: flex; + flex-direction: column; + gap: var(--gf-space-2); + } + .band-name { + font-size: var(--gf-font-size-xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + color: var(--gf-text-muted); + } + .band-channels { + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .channel-chip { + display: inline-flex; + align-items: baseline; + gap: var(--gf-space-1); + padding: var(--gf-space-1) var(--gf-space-2); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + background: var(--gf-surface); + cursor: pointer; + font-size: var(--gf-font-size-sm); + user-select: none; + transition: + border-color var(--gf-motion-fast) var(--gf-ease-out), + background var(--gf-motion-fast) var(--gf-ease-out); + } + .channel-chip.on { + border-color: var(--gf-accent); + background: var(--gf-accent-soft); + } + .channel-chip input { + margin: 0; + } + .chip-chan { + font-weight: var(--gf-font-weight-semibold); + } + .chip-mhz { + font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + font-variant-numeric: tabular-nums; + } + + .custom-row { + display: flex; + gap: var(--gf-space-2); + align-items: stretch; + } + .custom-row :global(input) { + flex: 1; + } + .custom-list { + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .custom-chip { + display: inline-flex; + align-items: center; + gap: var(--gf-space-1); + padding: var(--gf-space-1) var(--gf-space-2); + border: 1px solid var(--gf-accent); + border-radius: var(--gf-radius-pill); + background: var(--gf-accent-soft); + font-size: var(--gf-font-size-xs); + font-variant-numeric: tabular-nums; + } + .chip-x { + border: none; + background: none; + cursor: pointer; + color: var(--gf-text-muted); + font-size: var(--gf-font-size-md); + line-height: 1; + padding: 0; + } + .chip-x:hover { + color: var(--gf-danger); + } diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index d5a8131..eb80af8 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/svelte'; import { fireEvent, waitFor } from '@testing-library/dom'; -import type { Class, EventMeta, HeatSummary, Pilot, RoundDef } from '@gridfpv/types'; +import type { + ChannelCatalogEntry, + Class, + EventMeta, + HeatSummary, + Pilot, + RoundDef +} from '@gridfpv/types'; import EventRounds from '../src/screens/EventRounds.svelte'; import { makeTestSession } from './support.js'; @@ -35,6 +42,11 @@ const EVENT: EventMeta = { const FORMATS = ['double_elim', 'multi_main', 'round_robin', 'single_elim', 'timed_qual', 'zippyq']; +const CATALOG: ChannelCatalogEntry[] = [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'Fatshark', channel: 'F4', mhz: 5800 } +]; + function baseImpls() { return { listClassesImpl: vi.fn(async () => [OPEN, SPEC]), @@ -212,6 +224,55 @@ describe('EventRounds (Heats — fill round, heats list, manual build)', () => { expect(within(heatRow).getByText('Current')).toBeInTheDocument(); }); + it("renders each pilot's assigned channel as a band+channel label, custom MHz, or — (Slice 4b)", async () => { + const heat: HeatSummary = { + heat: 'q-1', + lineup: ['p1', 'p2'], + class: 'c1', + round: 'r1', + // p1 → a catalog channel (Raceband R1); p2 → a custom raw MHz with no catalog entry. + frequencies: [ + ['p1', 5658], + ['p2', 5685] + ], + phase: 'Scheduled', + is_current: false + }; + const { session } = makeTestSession({ + ...heatsImpls([heat]), + listChannelsImpl: vi.fn(async () => CATALOG), + event: EVENT_WITH_MEMBERS + }); + render(EventRounds, { session }); + + const heatRow = (await screen.findByText('q-1')).closest('.heat-row') as HTMLElement; + // The catalog frequency resolves to its band+channel; the custom one falls back to raw MHz. + await waitFor(() => expect(within(heatRow).getByText('Raceband R1')).toBeInTheDocument()); + expect(within(heatRow).getByText('5685 MHz')).toBeInTheDocument(); + }); + + it('shows — for a sim/free-text heat that carries no frequencies (Slice 4b)', async () => { + const heat: HeatSummary = { + heat: 'q-2', + lineup: ['p1', 'p2'], + class: 'c1', + round: 'r1', + phase: 'Scheduled', + is_current: false + }; + const { session } = makeTestSession({ + ...heatsImpls([heat]), + listChannelsImpl: vi.fn(async () => CATALOG), + event: EVENT_WITH_MEMBERS + }); + render(EventRounds, { session }); + + const heatRow = (await screen.findByText('q-2')).closest('.heat-row') as HTMLElement; + // Both pilots show the dash (no channel assigned). + const dashes = within(heatRow).getAllByText('—'); + expect(dashes.length).toBe(2); + }); + it('fills a round via FillRound and re-reads the heats list', async () => { const impls = heatsImpls([]); const newHeat: HeatSummary = { diff --git a/frontend/apps/rd-console/tests/TimersPage.test.ts b/frontend/apps/rd-console/tests/TimersPage.test.ts index 106e084..0007f22 100644 --- a/frontend/apps/rd-console/tests/TimersPage.test.ts +++ b/frontend/apps/rd-console/tests/TimersPage.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/svelte'; import { fireEvent, waitFor } from '@testing-library/dom'; -import type { Timer } from '@gridfpv/types'; +import type { ChannelCatalogEntry, Timer } from '@gridfpv/types'; import TimersPage from '../src/screens/TimersPage.svelte'; import { makeTestSession } from './support.js'; +const CATALOG: ChannelCatalogEntry[] = [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'Raceband', channel: 'R2', mhz: 5695 }, + { band: 'Fatshark', channel: 'F4', mhz: 5800 } +]; + /** The page hosts the shared TimerManager; tests render it with a no-op `onhome`. */ const noop = () => {}; @@ -67,9 +73,17 @@ describe('TimersPage (app-level timer registry)', () => { await fireEvent.click(screen.getByRole('button', { name: 'Add timer' })); await waitFor(() => expect(createTimerImpl).toHaveBeenCalledTimes(1)); + // The add carries the channel config too (race redesign Slice 4b): the permissive default — + // Flexible, 8 nodes, no channels picked — when the RD does not touch the channel fields. expect(createTimerImpl).toHaveBeenCalledWith( 'http://d.local', - { name: 'Fast', kind: { Mock: { laps: 3, lap_ms: 30000 } } }, + { + name: 'Fast', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] + }, 'tok' ); // The list reloaded and shows the new timer. @@ -93,10 +107,120 @@ describe('TimersPage (app-level timer registry)', () => { await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(updateTimerImpl).toHaveBeenCalledTimes(1)); + // The edit carries the timer's (unchanged) channel config too (race redesign Slice 4b). expect(updateTimerImpl).toHaveBeenCalledWith( 'http://d.local', 'rh-1', - { name: 'Renamed', kind: { Rotorhazard: { url: 'http://rh.local:5000' } } }, + { + name: 'Renamed', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] + }, + 'tok' + ); + }); + + it('configures a Flexible timer’s channels from the catalog + a custom MHz (Slice 4b)', async () => { + const created: Timer = { + id: 'flex-x', + name: 'Flex', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready', + channel_capability: 'Flexible', + node_count: 6, + available_channels: [5658, 5800, 5685] + }; + let calls = 0; + const listTimersImpl = vi.fn(async () => (calls++ === 0 ? [MOCK] : [MOCK, created])); + const createTimerImpl = vi.fn(async () => created); + const listChannelsImpl = vi.fn(async () => CATALOG); + const { session } = makeTestSession({ listTimersImpl, createTimerImpl, listChannelsImpl }); + render(TimersPage, { session, onhome: noop }); + + await screen.findByText('Mock'); + await fireEvent.click(screen.getByRole('button', { name: '+ Add timer' })); + + const name = (await screen.findByLabelText('Timer name')) as HTMLInputElement; + await fireEvent.input(name, { target: { value: 'Flex' } }); + + // Node count (the heat-size cap) is editable. + const nodes = screen.getByLabelText('Node count') as HTMLInputElement; + await fireEvent.input(nodes, { target: { value: '6' } }); + + // The catalog picker renders grouped by band; pick two channels (Raceband R1, Fatshark F4). + await waitFor(() => expect(screen.getByLabelText('Raceband R1, 5658 MHz')).toBeInTheDocument()); + await fireEvent.click(screen.getByLabelText('Raceband R1, 5658 MHz')); + await fireEvent.click(screen.getByLabelText('Fatshark F4, 5800 MHz')); + + // Add a custom raw-MHz channel (Flexible only). + const custom = screen.getByLabelText('Custom channel MHz') as HTMLInputElement; + await fireEvent.input(custom, { target: { value: '5685' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + await fireEvent.click(screen.getByRole('button', { name: 'Add timer' })); + + await waitFor(() => expect(createTimerImpl).toHaveBeenCalledTimes(1)); + expect(createTimerImpl).toHaveBeenCalledWith( + 'http://d.local', + { + name: 'Flex', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + channel_capability: 'Flexible', + node_count: 6, + // Catalog channels in catalog order, then the custom MHz. + available_channels: [5658, 5800, 5685] + }, + 'tok' + ); + }); + + it('limits a Fixed timer to its built-in allowed set (no custom) and builds Fixed (Slice 4b)', async () => { + // A timer whose Fixed allowed set is just two Raceband channels — the picker offers only those, + // and the custom-MHz row is hidden. + const fixed: Timer = { + id: 'fix-1', + name: 'Fixed RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Configured', + channel_capability: { Fixed: { channels: [5658, 5695] } }, + node_count: 2, + available_channels: [5658] + }; + const listTimersImpl = vi.fn(async () => [MOCK, fixed]); + const updateTimerImpl = vi.fn(async () => fixed); + const listChannelsImpl = vi.fn(async () => CATALOG); + const { session } = makeTestSession({ listTimersImpl, updateTimerImpl, listChannelsImpl }); + render(TimersPage, { session, onhome: noop }); + + await screen.findByText('Fixed RH'); + const list = screen.getByRole('list', { name: 'Configured timers' }); + const row = within(list).getAllByRole('listitem')[1]; + await fireEvent.click(within(row).getByRole('button', { name: 'Edit' })); + + // Only the Fixed allowed set is offered; Fatshark F4 (not allowed) is absent. + await waitFor(() => expect(screen.getByLabelText('Raceband R1, 5658 MHz')).toBeInTheDocument()); + expect(screen.getByLabelText('Raceband R2, 5695 MHz')).toBeInTheDocument(); + expect(screen.queryByLabelText('Fatshark F4, 5800 MHz')).toBeNull(); + // A Fixed timer offers no custom-MHz entry. + expect(screen.queryByLabelText('Custom channel MHz')).toBeNull(); + + // Make the second allowed channel available too, then save. + await fireEvent.click(screen.getByLabelText('Raceband R2, 5695 MHz')); + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(updateTimerImpl).toHaveBeenCalledTimes(1)); + expect(updateTimerImpl).toHaveBeenCalledWith( + 'http://d.local', + 'fix-1', + { + name: 'Fixed RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + channel_capability: { Fixed: { channels: [5658, 5695] } }, + node_count: 2, + available_channels: [5658, 5695] + }, 'tok' ); }); diff --git a/frontend/apps/rd-console/tests/channels.test.ts b/frontend/apps/rd-console/tests/channels.test.ts new file mode 100644 index 0000000..87f7e6d --- /dev/null +++ b/frontend/apps/rd-console/tests/channels.test.ts @@ -0,0 +1,104 @@ +/** + * Channel helper unit tests (race redesign Slice 4b): the catalog grouping/labelling the channel + * picker and the per-heat channel display rely on. + */ +import { describe, expect, it } from 'vitest'; +import type { ChannelCapability, ChannelCatalogEntry } from '@gridfpv/types'; +import { + capabilityTag, + catalogEntryFor, + channelLabel, + fixedAllowed, + groupByBand, + isCatalogChannel, + isPlausibleMhz, + offeredCatalog +} from '../src/lib/channels.js'; + +const CATALOG: ChannelCatalogEntry[] = [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'Raceband', channel: 'R2', mhz: 5695 }, + { band: 'Fatshark', channel: 'F4', mhz: 5800 }, + { band: 'DJI', channel: 'R1', mhz: 5660 } +]; + +describe('groupByBand', () => { + it('groups entries into bands, preserving catalog order across and within bands', () => { + const bands = groupByBand(CATALOG); + expect(bands.map((b) => b.band)).toEqual(['Raceband', 'Fatshark', 'DJI']); + expect(bands[0].entries.map((e) => e.channel)).toEqual(['R1', 'R2']); + expect(bands[1].entries).toHaveLength(1); + }); + + it('is empty for an empty catalog', () => { + expect(groupByBand([])).toEqual([]); + }); +}); + +describe('channelLabel', () => { + it('resolves a known MHz to its band + channel', () => { + expect(channelLabel(5658, CATALOG)).toBe('Raceband R1'); + expect(channelLabel(5800, CATALOG)).toBe('Fatshark F4'); + }); + + it('falls back to a raw " MHz" for a custom/unknown frequency', () => { + expect(channelLabel(5685, CATALOG)).toBe('5685 MHz'); + }); + + it('falls back to raw MHz when the catalog is unavailable', () => { + expect(channelLabel(5800, [])).toBe('5800 MHz'); + }); + + it('prefers the first catalog match (stable order) for a coincident frequency', () => { + // Two bands could share a frequency; the earlier (Raceband) wins. + const dupe: ChannelCatalogEntry[] = [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'HDZero', channel: 'R1', mhz: 5658 } + ]; + expect(channelLabel(5658, dupe)).toBe('Raceband R1'); + }); +}); + +describe('catalogEntryFor / isCatalogChannel', () => { + it('finds the entry for a known MHz, and reports catalog membership', () => { + expect(catalogEntryFor(5695, CATALOG)?.channel).toBe('R2'); + expect(isCatalogChannel(5695, CATALOG)).toBe(true); + expect(catalogEntryFor(5685, CATALOG)).toBeUndefined(); + expect(isCatalogChannel(5685, CATALOG)).toBe(false); + }); +}); + +describe('capabilityTag / fixedAllowed', () => { + it('reads Flexible and Fixed capabilities', () => { + expect(capabilityTag('Flexible')).toBe('Flexible'); + expect(capabilityTag(undefined)).toBe('Flexible'); + const fixed: ChannelCapability = { Fixed: { channels: [5658, 5695] } }; + expect(capabilityTag(fixed)).toBe('Fixed'); + expect(fixedAllowed(fixed)).toEqual([5658, 5695]); + expect(fixedAllowed('Flexible')).toEqual([]); + }); +}); + +describe('offeredCatalog', () => { + it('offers the whole catalog for a Flexible timer', () => { + expect(offeredCatalog('Flexible', CATALOG)).toHaveLength(CATALOG.length); + }); + + it('limits a Fixed timer to its built-in allowed set, in catalog order', () => { + const fixed: ChannelCapability = { Fixed: { channels: [5800, 5658] } }; + const offered = offeredCatalog(fixed, CATALOG); + expect(offered.map((e) => e.mhz)).toEqual([5658, 5800]); // catalog order, not allowed order + }); +}); + +describe('isPlausibleMhz', () => { + it('accepts a 5.8 GHz centre and rejects out-of-band / non-integer values', () => { + expect(isPlausibleMhz(5685)).toBe(true); + expect(isPlausibleMhz(5300)).toBe(true); + expect(isPlausibleMhz(6000)).toBe(true); + expect(isPlausibleMhz(1234)).toBe(false); + expect(isPlausibleMhz(6500)).toBe(false); + expect(isPlausibleMhz(5800.5)).toBe(false); + expect(isPlausibleMhz(Number.NaN)).toBe(false); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 64bbebd..fd1f29c 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -28,6 +28,7 @@ import type { setEventClasses, setClassMembership, listFormats, + listChannels, createRound, updateRound, deleteRound, @@ -77,6 +78,8 @@ export interface TimerImpls { setClassMembershipImpl?: typeof setClassMembership; /** The valid format-names read (race redesign Slice 2b) — backs the EventRounds tests. */ listFormatsImpl?: typeof listFormats; + /** The standard channel-catalog read (race redesign Slice 4b) — backs the Channels UI tests. */ + listChannelsImpl?: typeof listChannels; /** The per-event round write seams (race redesign Slice 2b) — back the EventRounds tests. */ createRoundImpl?: typeof createRound; updateRoundImpl?: typeof updateRound; @@ -153,6 +156,8 @@ export function makeTestSession( setClassMembershipImpl: opts?.setClassMembershipImpl, // Round read/write seams (race redesign Slice 2b): inert unless a test overrides them. listFormatsImpl: opts?.listFormatsImpl, + // Channel-catalog read seam (race redesign Slice 4b): inert unless a test overrides it. + listChannelsImpl: opts?.listChannelsImpl, createRoundImpl: opts?.createRoundImpl, updateRoundImpl: opts?.updateRoundImpl, deleteRoundImpl: opts?.deleteRoundImpl, diff --git a/frontend/contract/channels.contract.ts b/frontend/contract/channels.contract.ts new file mode 100644 index 0000000..30dc65f --- /dev/null +++ b/frontend/contract/channels.contract.ts @@ -0,0 +1,57 @@ +/** + * Channel catalog contract (race redesign Slice 4b): `GET /channels` + the real `listChannels` + * client helper. + * + * Reads the standard FPV channel catalog back through the real `@gridfpv/protocol-client`'s + * `listChannels` (an open read, no token), and asserts the served `ChannelCatalogEntry[]` is the + * band/channel ↔ raw-MHz vocabulary the Channels UI offers and labels heats with. If the new + * route, the binding, or the `catalog()` plumbing were wrong, the catalog would not come back. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { listChannels } from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { startContractDirector } from './harness.ts'; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector({}); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('GET /channels serves the standard FPV channel catalog', () => { + it('listChannels returns the band/channel/MHz catalog, open (no token)', async () => { + const catalog = await listChannels(director.baseUrl); + expect(catalog.length).toBeGreaterThan(0); + + // Every entry is a band + channel label + a plausible 5.8 GHz centre frequency. + for (const entry of catalog) { + expect(typeof entry.band).toBe('string'); + expect(entry.band.length).toBeGreaterThan(0); + expect(typeof entry.channel).toBe('string'); + expect(entry.channel.length).toBeGreaterThan(0); + expect(entry.mhz).toBeGreaterThanOrEqual(5600); + expect(entry.mhz).toBeLessThanOrEqual(6000); + } + + // The de-facto Raceband default is present in channel order (R1 = 5658, R8 = 5917) — the UI + // resolves a heat's raw 5658 MHz back to this "Raceband R1" label. + const raceband = catalog.filter((e) => e.band === 'Raceband'); + expect(raceband.map((e) => e.channel)).toEqual([ + 'R1', + 'R2', + 'R3', + 'R4', + 'R5', + 'R6', + 'R7', + 'R8' + ]); + expect(raceband[0].mhz).toBe(5658); + expect(raceband[7].mhz).toBe(5917); + }); +}); diff --git a/frontend/e2e/rounds.spec.ts b/frontend/e2e/rounds.spec.ts index 1a60847..dd34dac 100644 --- a/frontend/e2e/rounds.spec.ts +++ b/frontend/e2e/rounds.spec.ts @@ -223,3 +223,124 @@ test('RD fills a round and builds a heat by hand in the Heats UI', async ({ page await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [] } }); await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); }); + +/** + * The **Channels** UI (race redesign Slice 4b) — the deliverable proof for the channel config + + * per-heat channel labels. + * + * A real click-through: open the **Timers** page, edit the built-in **Mock** timer's channel config + * (Flexible: pick a couple of catalog channels + add a **custom raw-MHz** entry, set the node + * count), and save. Then set up a class + two members + a round over the real write paths, fill a + * heat, and assert each pilot's lineup row shows a resolved **channel label** (a band+channel from + * the catalog) — the assignment the primary (Mock) timer's available channels drive. Nothing mocked. + */ +test('RD configures a timer’s channels and a filled heat shows channel labels', async ({ + page, + director +}) => { + const base = director.baseUrl; + const ev = `${base}/events/practice`; + const json = { headers: { 'Content-Type': 'application/json' } }; + const SUFFIX = Date.now(); + const ACE = `E2E-Chan-Ace-${SUFFIX}`; + const BEE = `E2E-Chan-Bee-${SUFFIX}`; + const ROUND_LABEL = `E2E-ChanRound-${SUFFIX}`; + + await page.goto('/'); + await enterPractice(page); + + // ── Timers tab → edit the built-in Mock's channel config (Flexible + custom MHz + node count) ── + await openTab(page, 'Timers'); + const mockRow = page + .getByRole('list', { name: 'Configured timers' }) + .getByRole('listitem') + .filter({ hasText: 'Mock' }) + .first(); + await expect(mockRow).toBeVisible({ timeout: 15_000 }); + await mockRow.getByRole('button', { name: 'Edit' }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // Flexible capability, a 4-node cap, two catalog channels + one custom raw-MHz entry. + await dialog.getByLabel('Channel capability').selectOption('Flexible'); + await dialog.getByLabel('Node count').fill('4'); + await dialog.getByLabel('Raceband R1, 5658 MHz').check(); + await dialog.getByLabel('Raceband R2, 5695 MHz').check(); + // A truly non-catalog frequency, so it lands as a custom chip (not a catalog checkbox). + const custom = dialog.getByLabel('Custom channel MHz'); + await custom.fill('5670'); + await dialog.getByRole('button', { name: 'Add' }).click(); + await expect(dialog.getByText('5670 MHz')).toBeVisible(); + if (process.env.GRIDFPV_SHOTS) + await dialog.screenshot({ path: `${process.env.GRIDFPV_SHOTS}/timer-channel-config.png` }); + await dialog.getByRole('button', { name: 'Save changes' }).click(); + await expect(page.getByRole('dialog')).toBeHidden({ timeout: 15_000 }); + + // ── Set up a class + two members + a round over the real write paths ────────────────────────── + const classes = (await (await page.request.get(`${base}/classes`)).json()) as Array<{ + id: string; + name: string; + }>; + const classId = classes.find((c) => c.name === 'Open Class')!.id; + const mkPilot = async (callsign: string) => { + const p = (await ( + await page.request.post(`${base}/pilots`, { ...json, data: { callsign } }) + ).json()) as { id: string }; + return p.id; + }; + const aceId = await mkPilot(ACE); + const beeId = await mkPilot(BEE); + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [classId] } }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [aceId, beeId] } }); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [aceId, beeId] } + }); + const round = (await ( + await page.request.post(`${ev}/rounds`, { + ...json, + data: { + label: ROUND_LABEL, + classes: [classId], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap', + seeding: 'FromRoster' + } + }) + ).json()) as { id: string }; + + // ── Into the Heats UI → fill a heat and assert each pilot shows a resolved channel label ─────── + await page.goto('/'); + await enterPractice(page); + await openTab(page, 'Rounds & Heats'); + const heatRound = page.getByRole('region', { name: `Heats for ${ROUND_LABEL}` }); + await expect(heatRound).toBeVisible({ timeout: 15_000 }); + await heatRound.getByRole('button', { name: 'Fill next heat' }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + const filledRow = heatRound.locator('.heat-row').first(); + await expect(filledRow).toBeVisible({ timeout: 15_000 }); + // The two pilots are assigned the first two available channels (Raceband R1, R2) and the lineup + // shows their band+channel labels — the per-heat channel display resolving the raw MHz. + await expect(filledRow.locator('.lineup-chan').filter({ hasText: 'Raceband R1' })).toBeVisible(); + await expect(filledRow.locator('.lineup-chan').filter({ hasText: 'Raceband R2' })).toBeVisible(); + if (process.env.GRIDFPV_SHOTS) + await heatRound.screenshot({ path: `${process.env.GRIDFPV_SHOTS}/heat-channel-labels.png` }); + + // ── Clean up: round, membership, roster, class selection, and reset the Mock's channels. ────── + await page.request.delete(`${ev}/rounds/${round.id}`); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [] } + }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [] } }); + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); + await page.request.put(`${base}/timers/mock`, { + ...json, + data: { + channel_capability: 'Flexible', + node_count: 8, + available_channels: [5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917] + } + }); +}); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 5c4bb59..67d7e26 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -20,6 +20,7 @@ import type { ActiveEvent, ChangeEnvelope, + ChannelCatalogEntry, Class, ClassId, CreateClassRequest, @@ -813,6 +814,25 @@ export async function listFormats( return (await resp.json()) as string[]; } +/** + * List the standard **FPV channel catalog** (`GET /channels`) — race redesign Slice 4b. An open + * read (no token): the band/channel ↔ raw-MHz vocabulary the server compiles in, the single source + * of truth the Channels UI offers when picking a timer's available channels and reads back to label + * a heat's assigned frequencies. Resolves to the catalog in its stable order, or rejects on a + * non-2xx / transport failure. + */ +export async function listChannels( + baseUrl: string, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}/channels`, { headers }); + if (!resp.ok) throw new Error(`GET /channels failed: HTTP ${resp.status}`); + return (await resp.json()) as ChannelCatalogEntry[]; +} + /** * Add a **round** to an event (`POST /events/{id}/rounds`) — race redesign Slice 2b. RD-gated; the * round id is auto-generated server-side. The server validates each class is selected by the event, diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 1834ea9..8d1d40b 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -38,6 +38,7 @@ export { setEventClasses, setClassMembership, listFormats, + listChannels, createRound, updateRound, deleteRound, diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 411c69a..87e8351 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -29,6 +29,8 @@ export type * from '@bindings/ActiveEvent'; export type * from '@bindings/AdapterId'; export type * from '@bindings/Change'; export type * from '@bindings/ChangeEnvelope'; +export type * from '@bindings/ChannelCapability'; +export type * from '@bindings/ChannelCatalogEntry'; export type * from '@bindings/Class'; export type * from '@bindings/ClassId'; export type * from '@bindings/ClassMembership'; From c297dbb65067d2e94bc8f7553c95238e339ffd87 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 06:03:35 +0000 Subject: [PATCH 114/362] Race Slice 5/6a: round ranking + per-class standings projection + real class snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two read projections the bracket-carry display (Slice 5) and Results (Slice 6) share, plus the real class-scoped snapshot now that heats carry their class. Backend (gridfpv-server): - `GET /events/{id}/rounds/{round}/ranking` exposes the existing `round_engine::round_ranking` (the same ordering `FromRanking` seeds from) as a `Vec`. - `class_standings` fold in `round_engine.rs`: for a class, gather its rounds (any round whose eligible classes include the class), compute each round's ranking, award points (`field_size - position + 1`) and read laps/best-lap off the round's scored heats (`completed_heats` under the round's win_condition), then aggregate per pilot into an ordered `ClassStandings` (points desc, best-lap tie-break) — the season-join shape. Served at `GET /events/{id}/classes/{class}/standings`. - Real `snapshot_class`: `class_window` filters the log to the class's tagged heats (and the passes while one is active) before folding live state, so the class scope is a real filter instead of a whole-log passthrough. - Both are pure functions of log + meta (deterministic, replayable). New `ClassStanding`/`ClassStandings` ts-rs types. Tests: - round_engine: class_standings aggregates single/multi-round, excludes other classes' heats, is deterministic, empty for a class with no rounds. - app: snapshot_class filters to the queried class's heats. - race_flow e2e: after the qual heat, the ranking route matches the engine's FromRanking seeding order, the bracket lineup == the served ranking top-2, and the class standings aggregate the round per pilot. Frontend: - `roundRanking` / `classStandings` client helpers + barrel exports; barrel for the two new bindings. - `standings.contract.ts` drives a real round to Scored and asserts both routes round-trip. Results + advance-to-brackets UI is Slice 5/6b. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ClassStanding.ts | 43 +++ bindings/ClassStandings.ts | 21 ++ crates/app/tests/race_flow.rs | 61 ++++ crates/server/src/app.rs | 206 ++++++++++- crates/server/src/round_engine.rs | 331 +++++++++++++++++- frontend/contract/standings.contract.ts | 133 +++++++ .../packages/protocol-client/src/client.ts | 55 +++ .../packages/protocol-client/src/index.ts | 2 + frontend/packages/types/src/generated.ts | 2 + 9 files changed, 848 insertions(+), 6 deletions(-) create mode 100644 bindings/ClassStanding.ts create mode 100644 bindings/ClassStandings.ts create mode 100644 frontend/contract/standings.contract.ts 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/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index c9617d9..f3dc3d5 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -426,6 +426,67 @@ async fn round_driven_mock_race_flow_e2e() { 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); + // 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 diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8e6f935..953daa0 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -99,6 +99,7 @@ use crate::events::{ }; use crate::live_state::{HeatSummary, heat_summaries, live_state}; 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; @@ -347,6 +348,18 @@ pub fn router(registry: EventRegistry) -> Router { // 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)) + // 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 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)) @@ -970,6 +983,77 @@ async fn list_heats( 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(_) => 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}/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)) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// @@ -1133,21 +1217,67 @@ async fn snapshot_event( /// `GET /snapshot/class/{event}/{class}` — a class's live race-state (§4 class scope). /// -/// Class-level *filtering* of the log is deferred (the log carries no class tag yet — see -/// the module docs); this serves the whole-event live state under the class address so the -/// scope is reachable now and tightens later without an addressing change. +/// 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)>, + Path((event_id, _event, class)): Path<(EventId, EventId, ClassId)>, ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; + let class_events = class_window(&events, &class); Ok(Json(Snapshot { cursor, - body: ProjectionBody::LiveRaceState(live_state(&events)), + body: ProjectionBody::LiveRaceState(live_state(&class_events)), })) } +/// 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`] 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. +pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { + // 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 event in events { + match event { + Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } => { + active = class_heats.contains(heat); + if active { + window.push(event.clone()); + } + } + // Passes and adjudications belong to whichever heat is currently active. + _ if active => window.push(event.clone()), + _ => {} + } + } + window +} + /// `GET /snapshot/heat/{heat}` — the tightest scope (§4 heat scope). /// /// `?projection=live` (default) returns the heat's [`LiveRaceState`]; `?projection=laps` @@ -1578,6 +1708,72 @@ mod tests { 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![], + }, + 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![], + }, + 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![]); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 6d50c2f..5135a71 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -43,14 +43,18 @@ //! the bracket carry, where the *source* round's completed heats produce the ranking the //! bracket seeds from. +use std::collections::BTreeMap; + use gridfpv_engine::event::score_marshaled; use gridfpv_engine::format::{ CompletedHeat, FormatConfig, FormatRegistry, GeneratorStep, RankEntry, advance_top_n, }; use gridfpv_engine::heat::{HeatState, heat_state}; use gridfpv_engine::schedule::{Frequency, FrequencyPool, allocate}; -use gridfpv_engine::scoring::HeatResult; +use gridfpv_engine::scoring::{HeatResult, Metric}; use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, Pass, RoundId, SourceTime}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::events::{EventMeta, RoundDef, SeedingRule}; use crate::timers::{Timer, TimerRegistry}; @@ -389,6 +393,218 @@ pub fn round_ranking( Ok(generator.ranking(&completed)) } +// --- 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, + }); + } + } +} + +/// 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 **best lap** (µs) across a heat's [`HeatResult`], read off the scored +/// [`Metric`] each placement carries. Only [`Metric::BestLapMicros`] is a lap duration; the other +/// metrics are completion *times*, not durations, so they contribute no lap metric (the lap totals +/// still aggregate). `None` when the competitor has no placement or no lap. +fn placement_best_lap(result: &HeatResult, competitor: &CompetitorRef) -> Option { + result + .places + .iter() + .find(|p| &p.competitor.competitor == competitor) + .and_then(|p| match p.metric { + Metric::BestLapMicros(lap) => lap, + _ => None, + }) +} + +/// 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) + .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) { + let ranking = round_ranking(meta, round, events)?; + let field_size = ranking.len() as u32; + // The round's scored heats — the same view `round_ranking` ranked over — so the laps / + // best-lap a standing reports come from exactly the heats that decided the round position. + let completed = completed_heats(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 / best lap for this competitor across the round's heats. + let mut laps = 0u32; + let mut best_lap: Option = None; + for heat in &completed { + laps += placement_laps(&heat.result, &entry.competitor); + if let Some(lap) = placement_best_lap(&heat.result, &entry.competitor) { + best_lap = Some(match best_lap { + Some(existing) => existing.min(lap), + None => lap, + }); + } + } + acc.entry(entry.competitor.clone()) + .or_default() + .add_round(points, laps, best_lap); + } + } + + // 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. /// @@ -827,6 +1043,119 @@ mod tests { } } + // --- 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-Scored 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); + } + + #[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_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())); + } + #[test] fn fill_round_is_deterministic_on_replay() { let round = qual_round("q1", "open"); diff --git a/frontend/contract/standings.contract.ts b/frontend/contract/standings.contract.ts new file mode 100644 index 0000000..d379ce0 --- /dev/null +++ b/frontend/contract/standings.contract.ts @@ -0,0 +1,133 @@ +/** + * Round-ranking + class-standings contract (race redesign Slice 5/6a): the two read routes + * `GET /events/{id}/rounds/{round}/ranking` and `GET /events/{id}/classes/{class}/standings`, + * plus the real `roundRanking` / `classStandings` client helpers. + * + * Sets up a real class + roster + membership + round on the Practice event through the real client + * helpers, schedules that round's heat (tagged with the round + class), drives it to `Scored` with + * deterministic inserted laps (pilot A faster than B), then reads the two projections back through + * the real `@gridfpv/protocol-client`. Asserts the round ranking comes back best-first (A then B) + * and the class standings aggregate that round into one per-pilot row each, A leading on points. If + * the routes, the bindings, or the season-join fold were wrong, the shapes would not round-trip. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + classStandings, + createClass, + createPilot, + createRound, + PRACTICE_EVENT_ID, + roundRanking, + setClassMembership, + setEventClasses, + setEventRoster +} from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { driveToRunning, eventRoot, rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-standings-contract'; +const HEAT = 's-1'; + +let director: Director; + +beforeAll(async () => { + // A brisk sim (one quick lap) so the heat scores fast; the sim makes the top seed faster than + // the next, giving a deterministic A-before-B round ranking. + director = await startContractDirector({ token: TOKEN, simLaps: 1, simLapMs: 30 }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('GET round ranking + class standings serve the season-join projections', () => { + it('ranking is best-first and standings aggregate the round per pilot', async () => { + // Real directory setup: a class + two pilots, selected + rostered + membered on Practice. + const klass = await createClass(director.baseUrl, { name: 'Open' }, TOKEN); + const pilotA = await createPilot(director.baseUrl, { callsign: 'alpha' }, TOKEN); + const pilotB = await createPilot(director.baseUrl, { callsign: 'bravo' }, TOKEN); + + await setEventClasses(director.baseUrl, PRACTICE_EVENT_ID, [klass.id], TOKEN); + await setEventRoster(director.baseUrl, PRACTICE_EVENT_ID, [pilotA.id, pilotB.id], TOKEN); + await setClassMembership( + director.baseUrl, + PRACTICE_EVENT_ID, + klass.id, + [pilotA.id, pilotB.id], + TOKEN + ); + + // A single-round timed_qual over the class (BestLap), seeded from the roster membership. + const round = await createRound( + director.baseUrl, + PRACTICE_EVENT_ID, + { + label: 'Qualifying', + classes: [klass.id], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + seeding: 'FromRoster' + }, + TOKEN + ); + + // Schedule the round's heat tagged with the round + class; lineup is the two members. + expect( + ( + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { + heat: HEAT, + lineup: [pilotA.id, pilotB.id], + class: klass.id, + round: round.id + } + }) + ).ok + ).toBe(true); + + // Drive the heat to Running; the sim bridge emits a holeshot + one lap per pilot, with the + // top seed (A) faster than the next (B) — a deterministic round ranking. + await driveToRunning(director.baseUrl, TOKEN, HEAT); + + // Wait until both pilots have banked their lap (a `laps` projection shows a completed lap each) + // before closing the heat, so the scored result is final. + await waitForBothLaps(director.baseUrl, HEAT); + + // Close the heat: Finish → Score. + expect((await rdControl(director.baseUrl, TOKEN, { Finish: { heat: HEAT } })).ok).toBe(true); + expect((await rdControl(director.baseUrl, TOKEN, { Score: { heat: HEAT } })).ok).toBe(true); + + // 1) The round ranking: best-first, A ahead of B, positions 1-based. + const ranking = await roundRanking(director.baseUrl, PRACTICE_EVENT_ID, round.id); + expect(ranking.map((e) => e.competitor)).toEqual([pilotA.id, pilotB.id]); + expect(ranking[0].position).toBe(1); + + // 2) The class standings: one row per pilot, aggregated across the class's rounds, best first. + const standings = await classStandings(director.baseUrl, PRACTICE_EVENT_ID, klass.id); + expect(standings.class).toBe(klass.id); + expect(standings.standings.map((s) => s.competitor)).toEqual([pilotA.id, pilotB.id]); + expect(standings.standings[0].position).toBe(1); + // A leads on points (2-pilot field: winner = 2 points, runner-up = 1). + expect(standings.standings[0].points).toBeGreaterThan(standings.standings[1].points); + expect(standings.standings[0].rounds_entered).toBe(1); + }); +}); + +/** Poll the heat's `laps` projection until every competitor present has at least one completed lap. */ +async function waitForBothLaps(baseUrl: string, heat: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await fetch(`${eventRoot(baseUrl)}/snapshot/heat/${heat}?projection=laps`); + if (resp.ok) { + const snap = (await resp.json()) as { + body: { LapList?: { competitors: Array<{ laps: unknown[] }> } }; + }; + const competitors = snap.body.LapList?.competitors ?? []; + if (competitors.length >= 2 && competitors.every((c) => c.laps.length >= 1)) return; + } + await new Promise((r) => setTimeout(r, 30)); + } + throw new Error(`both pilots did not bank a lap within ${timeoutMs}ms`); +} diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 67d7e26..0a833b2 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -23,6 +23,7 @@ import type { ChannelCatalogEntry, Class, ClassId, + ClassStandings, CreateClassRequest, CreateEventRequest, CreatePilotRequest, @@ -36,6 +37,7 @@ import type { PilotId, ProjectionBody, ProtocolError, + RankEntry, RoundDef, RoundId, Scope, @@ -943,6 +945,59 @@ export async function listHeats( return (await resp.json()) as HeatSummary[]; } +/** + * Read a round's **ranking** (`GET /events/{id}/rounds/{round}/ranking`) — race redesign Slice 5/6a. + * A read (open, no token): the ordered per-pilot {@link RankEntry} list the engine seeds + * `FromRanking` from — the same provisional-or-final ordering a bracket carries — for the + * bracket-carry display. Resolves the ranking (best first), or rejects on a non-2xx / transport + * failure; an unknown event or round is a 404, an unscorable round a 400. + */ +export async function roundRanking( + baseUrl: string, + eventId: EventId, + roundId: RoundId, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl( + `${trimSlash(baseUrl)}${eventRoot(eventId)}/rounds/${encodeURIComponent(roundId)}/ranking`, + { headers } + ); + if (!resp.ok) + throw new Error(`GET /events/${eventId}/rounds/${roundId}/ranking failed: HTTP ${resp.status}`); + return (await resp.json()) as RankEntry[]; +} + +/** + * Read a class's **standings** (`GET /events/{id}/classes/{class}/standings`) — race redesign + * Slice 5/6a. A read (open, no token): the season-join {@link ClassStandings} the Results UI reads — + * one per-pilot row per competitor that raced the class, aggregated across the class's rounds + * (points, best lap, total laps), best standing first. Resolves the standings, or rejects on a + * non-2xx / transport failure; an unknown event is a 404, an unscorable class round a 400. A class + * with no rounds resolves to empty standings. + */ +export async function classStandings( + baseUrl: string, + eventId: EventId, + classId: ClassId, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl( + `${trimSlash(baseUrl)}${eventRoot(eventId)}/classes/${encodeURIComponent(classId)}/standings`, + { headers } + ); + if (!resp.ok) + throw new Error( + `GET /events/${eventId}/classes/${classId}/standings failed: HTTP ${resp.status}` + ); + return (await resp.json()) as ClassStandings; +} + /** * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake. * diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 8d1d40b..7782f3b 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -43,6 +43,8 @@ export { updateRound, deleteRound, listHeats, + roundRanking, + classStandings, PRACTICE_EVENT_ID } from './client.js'; export type { diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 87e8351..d159198 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -35,6 +35,8 @@ export type * from '@bindings/Class'; export type * from '@bindings/ClassId'; export type * from '@bindings/ClassMembership'; export type * from '@bindings/ClassSource'; +export type * from '@bindings/ClassStanding'; +export type * from '@bindings/ClassStandings'; export type * from '@bindings/Command'; export type * from '@bindings/CommandAck'; export type * from '@bindings/CompetitorKey'; From 041066c4a3aed76a53b9d2afe9aa2e504c206de3 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 06:19:54 +0000 Subject: [PATCH 115/362] Race Slice 5/6b: per-class Results/standings + advance-to-brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the season-join projections into the console UI: - session.svelte.ts: roundRanking() + classStandings() client helpers (the protocol-client functions existed; this exposes them on Session, with injectable impls for tests). - Results.svelte: per-class standings table (position, callsign, points, best lap µs→s.mmm via formatMicros, total laps, rounds entered) with a class selector and an empty state; blends with the existing event-level heat result / ranking / bracket projections. - EventRounds.svelte: per-round "Standings" view (roundRanking, callsigns in ranking order) and an "Advance to bracket" action that creates a single_elim round seeded FromRanking from the round, top_n defaulting to the largest power-of-two ≤ the round's field, then fills the seeded bracket heats. - lib/standings.ts: pure bracketTopNDefault() + advanceRoundReq() helpers. Tests: unit (power-of-two default, payload, µs formatting, empty states, standings/advance rendering) + a browser e2e (drives a heat to Scored on a real Director, asserts per-class standings populate, advances to a seeded single_elim bracket whose filled heat matches the ranking top-N). Gates green: build, check, lint, test, contract, e2e (14/14), cargo xtask ci. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 1 + .../apps/rd-console/src/lib/session.svelte.ts | 42 +++ frontend/apps/rd-console/src/lib/standings.ts | 56 ++++ .../rd-console/src/screens/EventRounds.svelte | 266 +++++++++++++++++- .../rd-console/src/screens/Results.svelte | 234 ++++++++++++++- .../apps/rd-console/tests/EventRounds.test.ts | 102 +++++++ .../rd-console/tests/ResultsScreen.test.ts | 95 ++++++- .../apps/rd-console/tests/standings.test.ts | 68 +++++ frontend/apps/rd-console/tests/support.ts | 13 +- frontend/e2e/rounds.spec.ts | 167 +++++++++++ 10 files changed, 1015 insertions(+), 29 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/standings.ts create mode 100644 frontend/apps/rd-console/tests/standings.test.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 40818ed..a894e6c 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -375,6 +375,7 @@ {:else if active === 'results'} { + const event = this.currentEvent; + if (!event) return Promise.resolve([]); + return this.#roundRankingImpl(this.baseUrl, event.id, roundId, { token: this.#token }); + } + + /** + * Read a **class's standings** (`GET /events/{id}/classes/{class}/standings`) — race redesign + * Slice 5/6a. The season-join {@link ClassStandings} the Results screen reads: one per-pilot row + * per competitor that raced the class, aggregated across the class's rounds (points, best lap, + * total laps, rounds entered), best first. No-op (resolves empty standings for the class) when no + * event is selected; rejects on a non-2xx / transport failure (a class with no rounds resolves to + * empty standings server-side). + */ + classStandings(classId: ClassId): Promise { + const event = this.currentEvent; + if (!event) return Promise.resolve({ class: classId, standings: [] }); + return this.#classStandingsImpl(this.baseUrl, event.id, classId, { token: this.#token }); + } + /** * Bind a timing-source competitor to a pilot for lap attribution (`Command::Register`) — the * IRL **binding** step of the Roster stage. A timing source (RotorHazard node, sim player) reports diff --git a/frontend/apps/rd-console/src/lib/standings.ts b/frontend/apps/rd-console/src/lib/standings.ts new file mode 100644 index 0000000..7dcf2d0 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/standings.ts @@ -0,0 +1,56 @@ +/** + * Results / standings + advance-to-bracket derivations (race redesign Slice 5/6b). + * + * Two pure helpers shared by the Results screen (per-class standings) and the Rounds stage + * (per-round ranking → "Advance to bracket"): + * + * - {@link bracketTopNDefault} — the default bracket size when advancing a round to a single-elim + * bracket: the largest power-of-two **≤ the round's field size** (8 pilots → 8, 9 → 8, 6 → 4), + * so a 9-up qualifier cuts to a clean 8-seed bracket. The RD can override it in the confirm. + * - {@link advanceRoundReq} — assembles the `single_elim` round request seeded `FromRanking` from a + * source round's ranking, top-N. The payload the bracket round is created with. + * + * Kept framework-pure (no Svelte) so they unit-test directly and the screen + stage share one + * source of truth. + */ + +import type { NewRoundReq, RoundDef, WinCondition } from '@gridfpv/types'; + +/** + * The largest power-of-two **≤ `fieldSize`** — the default top-N for a bracket cut. A bracket runs + * on a power-of-two seed count (8, 16, …); the sensible default is to take as many seeds as fit + * under the field without padding byes, so a 9-pilot qualifier defaults to a clean 8-seed bracket. + * A field of 0 or 1 floors at 1 (a degenerate single-entry bracket the RD can still grow). + */ +export function bracketTopNDefault(fieldSize: number): number { + const n = Math.floor(fieldSize); + if (n <= 1) return 1; + // Largest power of two ≤ n: 2 ^ floor(log2(n)). + return 2 ** Math.floor(Math.log2(n)); +} + +/** + * Assemble the request for the **bracket round** that advancing `source` produces: a `single_elim` + * round over the source round's same eligible classes, carrying the source round's win condition, + * seeded `FromRanking` from the source round's ranking, top-`topN`. The RD picks the `label` and may + * override `topN` in the confirm. The created round's heats are then filled (`fillRound`) into the + * seeded bracket matchups — editable thereafter like any manually-built round. + */ +export function advanceRoundReq(source: RoundDef, topN: number, label: string): NewRoundReq { + const win_condition: WinCondition = source.win_condition; + return { + label, + classes: [...source.classes], + format: 'single_elim', + params: {}, + win_condition, + seeding: { + FromRanking: { source_round: source.id, top_n: Math.max(1, Math.round(topN)) } + } + }; +} + +/** A sensible default label for the bracket round advancing `source` produces. */ +export function advanceRoundLabel(source: RoundDef): string { + return `${source.label} — Bracket`; +} diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 65d118d..eb229ed 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -30,12 +30,14 @@ NewRoundReq, Pilot, PilotId, + RankEntry, RoundDef, RoundId, SeedingRule, WinCondition } from '@gridfpv/types'; import { channelLabel } from '../lib/channels.js'; + import { advanceRoundLabel, advanceRoundReq, bracketTopNDefault } from '../lib/standings.js'; import type { Session } from '../lib/session.svelte.js'; let { session }: { session: Session } = $props(); @@ -154,6 +156,88 @@ } } + // --- Per-round ranking ("Standings") + advance-to-bracket (race redesign Slice 5/6b) ---------- + // Each round can show a compact ordered ranking (`session.roundRanking`) — the seeding source a + // bracket draws `FromRanking` from — and offers "Advance to bracket": create a new single_elim + // round seeded from this round's ranking, top-N defaulting to the largest power-of-two ≤ the + // round's field, then Fill it to generate the seeded bracket heats (which the heats list shows). + + // The expanded-standings round, its loaded ranking, and the in-flight load. Toggling reloads, so a + // freshly-scored heat re-aggregates the ranking. + let standingsRound = $state(undefined); + let standingsRows = $state([]); + let standingsLoading = $state(false); + let standingsError = $state(undefined); + + async function toggleStandings(round: RoundDef) { + if (standingsRound === round.id) { + standingsRound = undefined; + return; + } + standingsRound = round.id; + standingsRows = []; + standingsError = undefined; + standingsLoading = true; + try { + standingsRows = await session.roundRanking(round.id); + } catch (e) { + // An unscored / unscorable round 400s — surface it inline rather than as a row list. + standingsError = e instanceof Error ? e.message : String(e); + } finally { + standingsLoading = false; + } + } + + // The round's field size — the union of its eligible classes' membership (what the bracket cut is + // taken from). Drives the top_n default when advancing. + function roundFieldSize(round: RoundDef): number { + return buildEligibleMembers(round.id).length; + } + + // The advance-to-bracket confirm: which round, its proposed label + top_n (editable), in-flight. + let advanceRoundId = $state(undefined); + let advanceLabel = $state(''); + let advanceTopN = $state(8); + let advancing = $state(false); + + function openAdvance(round: RoundDef) { + advanceRoundId = round.id; + advanceLabel = advanceRoundLabel(round); + advanceTopN = bracketTopNDefault(roundFieldSize(round)); + } + function cancelAdvance() { + advanceRoundId = undefined; + advancing = false; + } + + // Create the seeded single_elim round, then immediately Fill its first bracket heat so the heats + // list shows the ranking-seeded matchups. The bracket is editable thereafter (manual build). + async function submitAdvance(source: RoundDef) { + if (advancing) return; + advancing = true; + try { + const req: NewRoundReq = advanceRoundReq( + source, + advanceTopN, + advanceLabel.trim() || advanceRoundLabel(source) + ); + const created = await session.createRound(req); + if (!created) { + toast.info('A control token is required to manage rounds.'); + return; + } + // Generate the seeded bracket heats from the ranking. + const ack = await session.fillRound(created.id); + if (ack.ok) await refreshHeats(); + toast.success(`Bracket “${created.label}” created, seeded from ${source.label}.`); + advanceRoundId = undefined; + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + advancing = false; + } + } + // --- Manual heat build (replaces the retired NewHeat free-text form) --------------------------- // Pick a round, then select from that round's **eligible class members** (real roster pilots, no // typed names) → schedule a heat tagged with the round + its single class. The heat id is @@ -650,17 +734,111 @@ : round.classes.map(className).join(', ') || '—'}
        - +
        + + + +
        + {#if standingsRound === round.id} +
        +

        Standings — seeds the bracket

        + {#if standingsLoading} +

        Loading standings…

        + {:else if standingsError} +

        + No ranking yet — score this round's heats first. +

        + {:else if standingsRows.length === 0} +

        No ranked competitors yet.

        + {:else} +
          + {#each standingsRows as entry (entry.competitor)} +
        1. + {entry.position} + {callsign(entry.competitor)} +
        2. + {/each} +
        + {/if} +
        + {/if} + + {#if advanceRoundId === round.id} +
        { + e.preventDefault(); + submitAdvance(round); + }} + > +

        Advance to bracket

        +

        + Creates a single_elim round seeded from + {round.label}'s ranking, then fills the seeded bracket heats. The + bracket is editable afterward. +

        +
        + + + + + + +
        +
        + + +
        +
        + {/if} + {#if heatsByRound(round.id).length === 0}

        No heats yet — Fill next heat to draw the first from this round’s field. @@ -934,6 +1112,76 @@ flex-wrap: wrap; gap: var(--gf-space-2); } + .heat-round-actions { + display: flex; + align-items: center; + gap: var(--gf-space-2); + flex-wrap: wrap; + } + .round-standings { + padding: var(--gf-space-3); + border: 1px solid var(--gf-border-subtle); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface); + display: flex; + flex-direction: column; + gap: var(--gf-space-2); + } + .standings-title { + margin: 0; + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text-muted); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + } + .standings-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--gf-space-1); + } + .standings-row { + display: flex; + align-items: center; + gap: var(--gf-space-2); + font-size: var(--gf-font-size-md); + color: var(--gf-text); + } + .standings-pos { + display: inline-grid; + place-items: center; + min-width: 1.7rem; + height: 1.7rem; + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-sunken); + color: var(--gf-text-secondary); + font-weight: var(--gf-font-weight-bold); + font-variant-numeric: tabular-nums; + } + .standings-call { + font-weight: var(--gf-font-weight-semibold); + } + .advance-form { + padding: var(--gf-space-3); + border: 1px solid var(--gf-accent); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface); + display: flex; + flex-direction: column; + gap: var(--gf-space-3); + } + .advance-note { + margin: 0; + font-size: var(--gf-font-size-sm); + color: var(--gf-text-secondary); + line-height: 1.5; + } + .advance-note strong { + color: var(--gf-text); + } .empty.small { font-size: var(--gf-font-size-sm); } diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index 87d604b..8f1798a 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -1,22 +1,50 @@ @@ -37,6 +127,66 @@ + {#if session} + + {#snippet actions()} + {#if eventClasses.length > 1} + + {/if} + {/snippet} + + {#if eventClasses.length === 0} +

        + This event selects no classes yet. Standings appear here once a class runs a round. +

        + {:else} + {#if eventClasses.length === 1} +

        {className(eventClasses[0].id)}

        + {/if} + {#if loading && rows.length === 0} +

        Loading standings…

        + {:else if rows.length === 0} +

        + Nothing scored for {className(selectedClass || eventClasses[0].id)} yet — + standings populate as this class's rounds are scored. +

        + {:else} + + + + + + + + + + + + + {#each rows as row (row.competitor)} + + + + + + + + + {/each} + +
        PosPilotPointsBest lapLapsRounds
        {row.position}{callsign(row.competitor)}{row.points}{formatMicros(row.best_lap_micros)}{row.total_laps}{row.rounds_entered}
        + {/if} + {/if} + + {/if} + {#if heatResult} @@ -44,7 +194,7 @@ {/if} {#if standings && standings.length > 0} - + {/if} @@ -55,7 +205,7 @@ {/if} - {#if !heatResult && !(standings && standings.length) && !(bracket && bracket.rounds.length)} + {#if !session && !hasEventProjection}

        No results yet. They appear here as heats are scored.

        @@ -79,7 +229,69 @@ letter-spacing: var(--gf-tracking-tight); } .empty { + margin: 0; + color: var(--gf-text-secondary); + font-size: var(--gf-font-size-md); + line-height: 1.5; + } + .empty strong { + color: var(--gf-text); + } + .class-name { + margin: 0 0 var(--gf-space-2); + font-size: var(--gf-font-size-lg); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + } + + .standings { + border-collapse: collapse; + width: 100%; + color: var(--gf-text); + font-size: var(--gf-font-size-md); + } + .standings th, + .standings td { + padding: var(--gf-space-3); + text-align: left; + } + .standings thead th { color: var(--gf-text-muted); - font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + font-size: var(--gf-font-size-xs); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + border-bottom: 1px solid var(--gf-border); + } + .standings tbody tr + tr td { + border-top: 1px solid var(--gf-border-subtle); + } + .standings .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + .standings .points { + font-weight: var(--gf-font-weight-bold); + color: var(--gf-text); + } + .standings .pilot { + font-weight: var(--gf-font-weight-semibold); + letter-spacing: var(--gf-tracking-tight); + } + .standings .pos { + width: 2.75em; + } + .badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.7em; + height: 1.7em; + padding: 0 0.35em; + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-alt, var(--gf-surface-sunken)); + color: var(--gf-text-secondary); + font-weight: var(--gf-font-weight-bold); + font-variant-numeric: tabular-nums; } diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index eb80af8..7323dc4 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -320,3 +320,105 @@ describe('EventRounds (Heats — fill round, heats list, manual build)', () => { }); }); }); + +describe('EventRounds (per-round standings + advance-to-bracket — Slice 5/6b)', () => { + // An event whose Open class has two members so a round has a field, with one Qualifying round. + const EVENT_WITH_MEMBERS: EventMeta = { + ...EVENT, + roster: ['p1', 'p2'], + classes_membership: [{ class: 'c1', pilots: ['p1', 'p2'] }] + }; + + function baseHeatsImpls() { + return { + ...baseImpls(), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl: vi.fn(async () => [] as HeatSummary[]) + }; + } + + it("toggles a round's standings, resolving competitors to callsigns in ranking order", async () => { + const roundRankingImpl = vi.fn(async (_b, _e, _round) => [ + { competitor: 'p1', position: 1 }, + { competitor: 'p2', position: 2 } + ]); + const { session } = makeTestSession({ + ...baseHeatsImpls(), + roundRankingImpl, + event: EVENT_WITH_MEMBERS + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Standings' })); + await waitFor(() => expect(roundRankingImpl).toHaveBeenCalled()); + // The round id was read; the ranking renders as ordered callsigns. + expect(roundRankingImpl.mock.calls[0][2]).toBe('r1'); + const panel = (await screen.findByLabelText(/Standings for Qualifying R1/i)) as HTMLElement; + expect(within(panel).getByText('AceOne')).toBeInTheDocument(); + expect(within(panel).getByText('Bolt')).toBeInTheDocument(); + }); + + it('surfaces an inline note when a round has no ranking yet (unscored 400s)', async () => { + const roundRankingImpl = vi.fn(async () => { + throw new Error('GET …/ranking failed: HTTP 400'); + }); + const { session } = makeTestSession({ + ...baseHeatsImpls(), + roundRankingImpl, + event: EVENT_WITH_MEMBERS + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Standings' })); + await waitFor(() => expect(screen.getByText(/No ranking yet/i)).toBeInTheDocument()); + }); + + it('advances a round to a single_elim bracket seeded FromRanking with a power-of-two top_n default', async () => { + // A 3-member field defaults top_n to 2 (largest power-of-two ≤ 3). + const EVENT_3: EventMeta = { + ...EVENT_WITH_MEMBERS, + roster: ['p1', 'p2', 'p3'], + classes_membership: [{ class: 'c1', pilots: ['p1', 'p2', 'p3'] }] + }; + const created: RoundDef = { + id: 'r2', + label: 'Qualifying R1 — Bracket', + classes: ['c1'], + format: 'single_elim', + params: {}, + win_condition: QUAL.win_condition, + seeding: { FromRanking: { source_round: 'r1', top_n: 2 } } + }; + const createRoundImpl = vi.fn(async (_b, _e, _req) => created); + const { session, sendSpy } = makeTestSession({ + ...baseHeatsImpls(), + createRoundImpl, + event: EVENT_3 + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Advance to bracket' })); + + // The top_n field defaults to the power-of-two ≤ 3 → 2. + const topN = (await screen.findByLabelText('Top N advance')) as HTMLInputElement; + expect(topN.value).toBe('2'); + // The label defaults to " — Bracket". + const label = screen.getByLabelText('Bracket label') as HTMLInputElement; + expect(label.value).toBe('Qualifying R1 — Bracket'); + + await fireEvent.click(screen.getByRole('button', { name: 'Create & fill bracket' })); + + await waitFor(() => expect(createRoundImpl).toHaveBeenCalledTimes(1)); + const [, , req] = createRoundImpl.mock.calls[0]; + expect(req).toMatchObject({ + classes: ['c1'], + format: 'single_elim', + seeding: { FromRanking: { source_round: 'r1', top_n: 2 } } + }); + // After creating, the bracket's first heat is filled (a FillRound on the new round). + await waitFor(() => expect(sendSpy.mock.calls.some((c) => 'FillRound' in c[0])).toBe(true)); + expect(sendSpy.mock.calls.find((c) => 'FillRound' in c[0])![0]).toEqual({ + FillRound: { round: 'r2' } + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts index 5773c1b..4a3c54f 100644 --- a/frontend/apps/rd-console/tests/ResultsScreen.test.ts +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -1,19 +1,100 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { waitFor } from '@testing-library/dom'; +import type { Class, ClassStandings, EventMeta, Pilot } from '@gridfpv/types'; import Results from '../src/screens/Results.svelte'; import { heatResult, standings, eventOutcome } from './fixtures.js'; +import { makeTestSession } from './support.js'; -describe('Results', () => { - it('renders a heat result, standings, and bracket from typed fixtures', () => { +const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; +const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [], attributes: {} }; +const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [], attributes: {} }; + +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + roster: ['p1', 'p2'], + classes: ['c1'] +}; + +const STANDINGS: ClassStandings = { + class: 'c1', + standings: [ + { + competitor: 'p1', + position: 1, + points: 6, + best_lap_micros: 41_250_000, // → "41.250" + total_laps: 9, + rounds_entered: 2 + }, + { + competitor: 'p2', + position: 2, + points: 3, + best_lap_micros: null, // → "—" + total_laps: 4, + rounds_entered: 2 + } + ] +}; + +describe('Results — per-class standings (race redesign Slice 5/6b)', () => { + it('renders a class standings table with callsign, points, best lap (µs→s.mmm), laps, and rounds', async () => { + const { session } = makeTestSession({ + event: EVENT, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + classStandingsImpl: vi.fn(async () => STANDINGS) + }); + render(Results, { session }); + + const table = (await screen.findByLabelText(/Open standings/i)) as HTMLElement; + // Pilot 1's row: resolved callsign, points, best lap formatted µs → "41.250", laps, rounds. + const aceCell = within(table).getByText('AceOne'); + const aceRow = aceCell.closest('tr') as HTMLElement; + expect(within(aceRow).getByText('6')).toBeInTheDocument(); + expect(within(aceRow).getByText('41.250')).toBeInTheDocument(); + expect(within(aceRow).getByText('9')).toBeInTheDocument(); + // Pilot 2 has no best lap → renders a dash. + const boltRow = within(table).getByText('Bolt').closest('tr') as HTMLElement; + expect(within(boltRow).getByText('—')).toBeInTheDocument(); + }); + + it('shows an empty state for a class with no scored rounds yet', async () => { + const { session } = makeTestSession({ + event: EVENT, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + classStandingsImpl: vi.fn(async () => ({ class: 'c1', standings: [] })) + }); + render(Results, { session }); + await waitFor(() => expect(screen.getByText(/Nothing scored/i)).toBeInTheDocument()); + }); + + it('shows a no-classes message when the event selects none', async () => { + const { session } = makeTestSession({ + event: { ...EVENT, classes: [] }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]) + }); + render(Results, { session }); + expect(await screen.findByText(/selects no classes yet/i)).toBeInTheDocument(); + }); +}); + +describe('Results — event-level projections (kept from #56)', () => { + it('renders a heat result, ranking, and bracket from typed fixtures', () => { render(Results, { heatResult, standings, outcome: eventOutcome }); - // Leaderboard + standings list competitors. expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); - // Bracket round names from the derivation. expect(screen.getByText('Final')).toBeInTheDocument(); expect(screen.getByText('Semifinals')).toBeInTheDocument(); }); - it('shows an empty state when nothing is scored yet', () => { + it('shows an empty state when nothing is scored yet and no session', () => { render(Results, {}); expect(screen.getByText(/No results yet/i)).toBeInTheDocument(); }); diff --git a/frontend/apps/rd-console/tests/standings.test.ts b/frontend/apps/rd-console/tests/standings.test.ts new file mode 100644 index 0000000..691d96e --- /dev/null +++ b/frontend/apps/rd-console/tests/standings.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { RoundDef } from '@gridfpv/types'; +import { advanceRoundLabel, advanceRoundReq, bracketTopNDefault } from '../src/lib/standings.js'; + +describe('bracketTopNDefault — largest power-of-two ≤ field size', () => { + it('returns the field exactly when it is already a power of two', () => { + expect(bracketTopNDefault(2)).toBe(2); + expect(bracketTopNDefault(4)).toBe(4); + expect(bracketTopNDefault(8)).toBe(8); + expect(bracketTopNDefault(16)).toBe(16); + }); + + it('floors down to the largest power-of-two below an off-size field', () => { + expect(bracketTopNDefault(3)).toBe(2); + expect(bracketTopNDefault(6)).toBe(4); + expect(bracketTopNDefault(9)).toBe(8); // a 9-up qualifier cuts to a clean 8-seed bracket + expect(bracketTopNDefault(15)).toBe(8); + expect(bracketTopNDefault(17)).toBe(16); + }); + + it('floors at 1 for a degenerate field of 0 or 1', () => { + expect(bracketTopNDefault(0)).toBe(1); + expect(bracketTopNDefault(1)).toBe(1); + }); +}); + +describe('advanceRoundReq — the seeded single_elim payload', () => { + const SOURCE: RoundDef = { + id: 'r1', + label: 'Qualifying', + classes: ['c1', 'c2'], + format: 'timed_qual', + params: { rounds: '2' }, + win_condition: { Timed: { window_micros: 120_000_000 } }, + seeding: 'FromRoster' + }; + + it('builds a single_elim round seeded FromRanking from the source round, carrying its classes + win condition', () => { + const req = advanceRoundReq(SOURCE, 8, 'Qualifying — Bracket'); + expect(req).toEqual({ + label: 'Qualifying — Bracket', + classes: ['c1', 'c2'], + format: 'single_elim', + params: {}, + win_condition: { Timed: { window_micros: 120_000_000 } }, + seeding: { FromRanking: { source_round: 'r1', top_n: 8 } } + }); + }); + + it('clamps a non-integer / sub-1 top_n to at least 1', () => { + expect(advanceRoundReq(SOURCE, 0, 'x').seeding).toEqual({ + FromRanking: { source_round: 'r1', top_n: 1 } + }); + expect(advanceRoundReq(SOURCE, 4.7, 'x').seeding).toEqual({ + FromRanking: { source_round: 'r1', top_n: 5 } + }); + }); + + it('does not mutate the source round classes array', () => { + const req = advanceRoundReq(SOURCE, 8, 'x'); + expect(req.classes).not.toBe(SOURCE.classes); + expect(req.classes).toEqual(SOURCE.classes); + }); + + it('proposes a sensible default bracket label', () => { + expect(advanceRoundLabel(SOURCE)).toBe('Qualifying — Bracket'); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index fd1f29c..0b7fb14 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -32,7 +32,9 @@ import type { createRound, updateRound, deleteRound, - listHeats + listHeats, + roundRanking, + classStandings } from '@gridfpv/protocol-client'; import type { Command, CommandAck, EventMeta, LiveRaceState } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; @@ -86,6 +88,10 @@ export interface TimerImpls { deleteRoundImpl?: typeof deleteRound; /** The scheduled-heats read (race redesign Slice 3b) — backs the EventRounds Heats UI tests. */ listHeatsImpl?: typeof listHeats; + /** The round-ranking + class-standings reads (race redesign Slice 5/6a) — back the Results + + * EventRounds standings/advance tests. */ + roundRankingImpl?: typeof roundRanking; + classStandingsImpl?: typeof classStandings; } export interface TestSession { @@ -162,7 +168,10 @@ export function makeTestSession( updateRoundImpl: opts?.updateRoundImpl, deleteRoundImpl: opts?.deleteRoundImpl, // Scheduled-heats read seam (race redesign Slice 3b): inert unless a test overrides it. - listHeatsImpl: opts?.listHeatsImpl + listHeatsImpl: opts?.listHeatsImpl, + // Ranking + standings read seams (race redesign Slice 5/6a): inert unless overridden. + roundRankingImpl: opts?.roundRankingImpl, + classStandingsImpl: opts?.classStandingsImpl }); // Seed a token (so privileged sends don't trigger the lazy prompt) and enter the event, // unless the test wants the app-level (no-event) context. diff --git a/frontend/e2e/rounds.spec.ts b/frontend/e2e/rounds.spec.ts index dd34dac..f0ef945 100644 --- a/frontend/e2e/rounds.spec.ts +++ b/frontend/e2e/rounds.spec.ts @@ -344,3 +344,170 @@ test('RD configures a timer’s channels and a filled heat shows channel labels' } }); }); + +/** + * **Results (per-class standings) + Advance to bracket** (race redesign Slice 5/6b) — the deliverable + * proof. + * + * The prerequisites (a class with two members + a `timed_qual` round, its heat filled) are set up + * over the real REST/control path; then the test drives the **UI**: it runs the filled heat to + * **Scored** (Stage → Arm → Start, lets the sim emit laps, Finish → Score), opens **Results** and + * asserts the **per-class standings populate** with the two pilots, then back in **Rounds & Heats** + * **Advances to bracket** — asserting a `single_elim` round is created seeded `FromRanking`, and its + * filled heat's lineup matches the round ranking's top-N. Nothing about the standings/advance UI is + * mocked. + */ +test('RD reads per-class standings, then advances a round to a seeded bracket', async ({ + page, + director +}) => { + const base = director.baseUrl; + const ev = `${base}/events/practice`; + const json = { headers: { 'Content-Type': 'application/json' } }; + const SUFFIX = Date.now(); + const ACE = `E2E-Std-Ace-${SUFFIX}`; + const BEE = `E2E-Std-Bee-${SUFFIX}`; + const ROUND_LABEL = `E2E-StdRound-${SUFFIX}`; + const HEAT_ID = `e2e-std-${SUFFIX}`; + + // ── Set up over the real write paths: Open Class selected, two members, a round + a heat ────── + const classes = (await (await page.request.get(`${base}/classes`)).json()) as Array<{ + id: string; + name: string; + }>; + const classId = classes.find((c) => c.name === 'Open Class')!.id; + const mkPilot = async (callsign: string) => { + const p = (await ( + await page.request.post(`${base}/pilots`, { ...json, data: { callsign } }) + ).json()) as { id: string }; + return p.id; + }; + const aceId = await mkPilot(ACE); + const beeId = await mkPilot(BEE); + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [classId] } }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [aceId, beeId] } }); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [aceId, beeId] } + }); + const round = (await ( + await page.request.post(`${ev}/rounds`, { + ...json, + data: { + label: ROUND_LABEL, + classes: [classId], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + seeding: 'FromRoster' + } + }) + ).json()) as { id: string }; + // Schedule the round's heat tagged with the round + class so it lands in the round's list. + const sched = await page.request.post(`${ev}/control`, { + ...json, + data: { + ScheduleHeat: { heat: HEAT_ID, lineup: [aceId, beeId], class: classId, round: round.id } + } + }); + expect(sched.ok()).toBeTruthy(); + + // ── Drive the heat to Scored through Live control ───────────────────────────────────────────── + await page.goto('/'); + await enterPractice(page); + await expect(page.locator('.conn-label')).toHaveText('live', { timeout: 15_000 }); + // The scheduled heat is current; run it. + await expect(page.locator('.heat-id .value')).toHaveText(HEAT_ID, { timeout: 15_000 }); + await page.getByRole('button', { name: 'Stage', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Staged'); + await page.getByRole('button', { name: 'Arm', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Armed'); + await page.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Running'); + + // Let the sim bank some laps before closing the heat, so the scored result is real. + const heatSheet = page.getByRole('region', { name: 'Heat sheet' }); + const lapCells = heatSheet.locator('.laps'); + const totalLaps = async () => { + const texts = await lapCells.allTextContents(); + return texts.reduce((sum, t) => sum + (parseInt(t.match(/(\d+)/)?.[1] ?? '0', 10) || 0), 0); + }; + await expect.poll(totalLaps, { timeout: 30_000 }).toBeGreaterThan(1); + + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Finished'); + await page.getByRole('button', { name: 'Score', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Scored'); + + // ── Results → the per-class standings populate with both pilots ─────────────────────────────── + await openTab(page, 'Results'); + const standings = page.getByRole('table', { name: /Open Class standings/i }); + await expect(standings).toBeVisible({ timeout: 15_000 }); + await expect(standings.getByText(ACE)).toBeVisible(); + await expect(standings.getByText(BEE)).toBeVisible(); + if (process.env.GRIDFPV_SHOTS) + await page + .getByRole('region', { name: 'Results' }) + .screenshot({ path: `${process.env.GRIDFPV_SHOTS}/class-standings.png` }); + + // ── Rounds & Heats → Advance the qualifying round to a seeded bracket ───────────────────────── + await openTab(page, 'Rounds & Heats'); + const heatRound = page.getByRole('region', { name: `Heats for ${ROUND_LABEL}` }); + await expect(heatRound).toBeVisible({ timeout: 15_000 }); + await heatRound.getByRole('button', { name: 'Advance to bracket' }).click(); + const advanceForm = page.getByRole('form', { name: `Advance ${ROUND_LABEL} to bracket` }); + await expect(advanceForm).toBeVisible(); + // The top_n defaults to the largest power-of-two ≤ the 2-pilot field → 2. + await expect(advanceForm.getByLabel('Top N advance')).toHaveValue('2'); + if (process.env.GRIDFPV_SHOTS) + await advanceForm.screenshot({ path: `${process.env.GRIDFPV_SHOTS}/advance-to-bracket.png` }); + await page.getByRole('button', { name: 'Create & fill bracket' }).click(); + await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); + + // A new single_elim bracket round was created on the Director, seeded FromRanking from the source. + // (`GET /events` is the events list; pick the Practice event off it to read its rounds.) + const practiceRounds = async (): Promise< + Array<{ id: string; format: string; seeding: unknown }> + > => { + const events = (await (await page.request.get(`${base}/events`)).json()) as Array<{ + id: string; + rounds?: Array<{ id: string; format: string; seeding: unknown }>; + }>; + return events.find((e) => e.id === 'practice')?.rounds ?? []; + }; + await expect + .poll( + async () => + (await practiceRounds()).find( + (r) => + r.format === 'single_elim' && + typeof r.seeding === 'object' && + r.seeding !== null && + 'FromRanking' in (r.seeding as Record) + ), + { timeout: 15_000 } + ) + .toBeTruthy(); + + // Its seeded bracket heat lists with the ranking's top-N (the two pilots) in the lineup. + const bracketLabel = `${ROUND_LABEL} — Bracket`; + const bracketRound = page.getByRole('region', { name: `Heats for ${bracketLabel}` }); + await expect(bracketRound).toBeVisible({ timeout: 15_000 }); + const bracketHeat = bracketRound.locator('.heat-row').first(); + await expect(bracketHeat).toBeVisible({ timeout: 15_000 }); + await expect(bracketHeat.getByText(ACE)).toBeVisible(); + await expect(bracketHeat.getByText(BEE)).toBeVisible(); + if (process.env.GRIDFPV_SHOTS) + await bracketRound.screenshot({ + path: `${process.env.GRIDFPV_SHOTS}/seeded-bracket-heats.png` + }); + + // ── Clean up the shared Director's event back to empty. ─────────────────────────────────────── + for (const r of await practiceRounds()) await page.request.delete(`${ev}/rounds/${r.id}`); + await page.request.put(`${ev}/classes/${classId}/membership`, { + ...json, + data: { pilot_ids: [] } + }); + await page.request.put(`${ev}/roster`, { ...json, data: { pilot_ids: [] } }); + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); +}); From ec754d90e1d067fce597f81fd17d6332d58a29a9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 06:39:30 +0000 Subject: [PATCH 116/362] Race Slice 7: setup wizard (guided first-pass over the stage-pages); retire lib/setup.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event workspace is the editable stage-pages — there is no "Setup" tab. This adds a guided first-pass **overlay** that walks a new event through Classes → Roster → Timer & channels → First round → Review, **embedding the actual stage components** (EventClasses / EventRoster / EventTimers / EventRounds) and reusing the SAME commands they use (setEventClasses / setEventRoster + setClassMembership / setEventTimers / createRound). No separate config bag: everything the wizard sets is persisted by the stage itself and stays editable on the pages. Optional + re-runnable. - New EventSetupWizard.svelte: full-screen overlay stepper (Back/Next/Skip + a clickable progress indicator), field-readable (large text, dark). The Review step shows a gentle "ready to race?" summary read straight off the live event (classes ≥1, ≥1 placed member, a timer, ≥1 round). - Launched on creating a new event (the Events page offers "Set up event", default on) and from a "Setup wizard" button in the workspace header. - Retire the old local-only setup: delete lib/setup.ts (the never-persisted EventConfig), the old SetupWizard.svelte + its tests; inline the one helper heat.ts still used (scheduleHeatCommand). Remove the now-dead "setup" workspace tab from route.ts + App.svelte. - Unit tests for the stepper (navigation, Skip, the readiness summary, open/close); a browser e2e walking create → wizard → class → pilot → timer → round → finish, then asserting the workspace's pages reflect it all (same data, editable). e2e passes against a real Director in headless chromium; screenshots captured for the Classes + Review steps. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 125 +++-- frontend/apps/rd-console/src/lib/heat.ts | 6 +- frontend/apps/rd-console/src/lib/route.ts | 4 +- frontend/apps/rd-console/src/lib/setup.ts | 113 ---- .../rd-console/src/screens/EventPicker.svelte | 63 ++- .../src/screens/EventSetupWizard.svelte | 486 ++++++++++++++++++ .../rd-console/src/screens/SetupWizard.svelte | 387 -------------- .../rd-console/tests/EventSetupWizard.test.ts | 171 ++++++ .../apps/rd-console/tests/SetupWizard.test.ts | 36 -- frontend/apps/rd-console/tests/setup.test.ts | 47 -- frontend/e2e/wizard.spec.ts | 159 ++++++ 11 files changed, 968 insertions(+), 629 deletions(-) delete mode 100644 frontend/apps/rd-console/src/lib/setup.ts create mode 100644 frontend/apps/rd-console/src/screens/EventSetupWizard.svelte delete mode 100644 frontend/apps/rd-console/src/screens/SetupWizard.svelte create mode 100644 frontend/apps/rd-console/tests/EventSetupWizard.test.ts delete mode 100644 frontend/apps/rd-console/tests/SetupWizard.test.ts delete mode 100644 frontend/apps/rd-console/tests/setup.test.ts create mode 100644 frontend/e2e/wizard.spec.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index a894e6c..d64655f 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -28,14 +28,13 @@ import { Session } from './lib/session.svelte.js'; import ContextHeader from './ContextHeader.svelte'; import Breadcrumbs from './Breadcrumbs.svelte'; - import { emptyConfig, type EventConfig } from './lib/setup.js'; import HomeHub from './screens/HomeHub.svelte'; import EventPicker from './screens/EventPicker.svelte'; import TimersPage from './screens/TimersPage.svelte'; import PilotsPage from './screens/PilotsPage.svelte'; import ClassesPage from './screens/ClassesPage.svelte'; import TokenDialog from './screens/TokenDialog.svelte'; - import SetupWizard from './screens/SetupWizard.svelte'; + import EventSetupWizard from './screens/EventSetupWizard.svelte'; import EventTimers from './screens/EventTimers.svelte'; import EventRoster from './screens/EventRoster.svelte'; import EventClasses from './screens/EventClasses.svelte'; @@ -124,42 +123,33 @@ } }); - type ScreenId = - | 'setup' - | 'timers' - | 'roster' - | 'classes' - | 'rounds' - | 'live' - | 'marshaling' - | 'results'; + type ScreenId = 'timers' | 'roster' | 'classes' | 'rounds' | 'live' | 'marshaling' | 'results'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ - { id: 'setup', label: 'Setup', key: '1', icon: 'M4 6h16M4 12h16M4 18h10' }, { id: 'roster', label: 'Roster', - key: '2', + key: '1', icon: 'M16 11V7a4 4 0 1 0-8 0v4M5 11h14v9H5z' }, { id: 'classes', label: 'Classes', - key: '3', + key: '2', icon: 'M4 6h16M4 12h10M4 18h7M18 14l2 2 3-4' }, { id: 'rounds', label: 'Rounds & Heats', - key: '4', + key: '3', icon: 'M4 5h16M4 12h16M4 19h16M9 5v14' }, - { id: 'live', label: 'Live control', key: '5', icon: 'M5 3l14 9-14 9V3z' }, - { id: 'marshaling', label: 'Marshaling', key: '6', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, - { id: 'results', label: 'Results', key: '7', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, + { id: 'live', label: 'Live control', key: '4', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'marshaling', label: 'Marshaling', key: '5', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, + { id: 'results', label: 'Results', key: '6', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, { id: 'timers', label: 'Timers', - key: '8', + key: '7', icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z' } ]; @@ -170,22 +160,26 @@ const activeScreen = $derived(SCREENS.find((s) => s.id === active)); const setTab = (tab: WorkspaceTab) => navigate({ kind: 'workspace', tab }); - // The setup wizard's config lives at the shell so it survives screen switches. - let config = $state(emptyConfig()); - - function onSetupCommit() { - // The console is already inside an event (#72) — the live read client was scoped to it on - // entry — so committing the wizard just advances to registration; there is no separate - // event to re-scope to (the redundant event field was removed, #72 Slice 1b A1). - setTab('roster'); - } + // The setup wizard overlay (race redesign Slice 7): a guided first-pass over the stage-pages, + // reusing their components/commands (no separate config bag). Optional + re-runnable; launched on + // creating a new event (deferred via `pendingWizard` until the workspace mounts) and from the + // workspace header's "Setup wizard" button. Everything it sets stays editable on the pages. + let wizardOpen = $state(false); + // When a new event is created from the Events page, defer opening the wizard until the workspace + // route is in (the embedded stages need the active event), then pop it open once. + let pendingWizard = $state(false); + $effect(() => { + if (pendingWizard && session.currentEvent && route.kind === 'workspace') { + pendingWizard = false; + wizardOpen = true; + } + }); function leaveToPicker() { session.leaveEvent(); - // Reset the workspace's local view so re-entering starts clean, and land back on the Events - // page (the app route the "Switch event" action conceptually returns to). Setting the hash to - // `#/events` keeps the URL honest after leaving the workspace. - config = emptyConfig(); + // Close the wizard if open, and land back on the Events page (the app route the "Switch event" + // action conceptually returns to). Setting the hash to `#/events` keeps the URL honest. + wizardOpen = false; goPage('events'); } @@ -193,7 +187,7 @@ // "Home" crumb both use this. Same teardown as a switch, but lands on the hub, not the picker. function goToHubFromWorkspace() { session.leaveEvent(); - config = emptyConfig(); + wizardOpen = false; goHome(); } @@ -255,7 +249,7 @@ ontimers={() => goPage('timers')} /> {:else if route$page === 'events'} - + (pendingWizard = true)} /> {:else if route$page === 'timers'} {:else if route$page === 'pilots'} @@ -333,6 +327,24 @@ setTab('live')} onswitchevent={leaveToPicker} />

        {activeScreen?.label}

        +
        + + +
      {:else} + {#snippet footer()} @@ -538,4 +567,36 @@ gap: var(--gf-space-3); margin-top: var(--gf-space-3); } + .setup-opt { + display: flex; + align-items: flex-start; + gap: var(--gf-space-3); + padding: var(--gf-space-3); + border: 1px solid var(--gf-border-subtle); + border-radius: var(--gf-radius-md); + background: var(--gf-surface-alt); + cursor: pointer; + } + .setup-opt input { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.15rem; + accent-color: var(--gf-accent); + flex-shrink: 0; + cursor: pointer; + } + .setup-opt-text { + display: flex; + flex-direction: column; + gap: 2px; + } + .setup-opt-title { + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + } + .setup-opt-sub { + font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + } diff --git a/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte new file mode 100644 index 0000000..1632a57 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte @@ -0,0 +1,486 @@ + + + + + +{#if open} + + +{/if} + + diff --git a/frontend/apps/rd-console/src/screens/SetupWizard.svelte b/frontend/apps/rd-console/src/screens/SetupWizard.svelte deleted file mode 100644 index fcd1245..0000000 --- a/frontend/apps/rd-console/src/screens/SetupWizard.svelte +++ /dev/null @@ -1,387 +0,0 @@ - - -
      -
        - {#each steps as s, i (s)} -
      1. - -
      2. - {/each} -
      - -
      - {#if step === 0} -

      Event

      - -

      - Configuring {eventName || 'this event'}. -

      -

      Set up the classes, track, and format for this event below.

      - {:else if step === 1} -

      Classes

      - {#if config.classes.length === 0} -

      No classes yet.

      - {/if} - {#each config.classes as cls, i (i)} -
      - - - -
      - {/each} - - {:else if step === 2} -

      Track

      - - {:else if step === 3} -

      Format & win condition

      - {#if config.classes.length === 0} -

      Add a class first.

      - {/if} - {#each config.classes as cls, i (i)} -
      - {cls.name} - - {#if cls.winCondition !== 'BestLap'} - - {:else} - Best single lap. - {/if} -
      - {/each} - {:else} -

      Review

      -
      -
      Event
      -
      {eventName || 'this event'}
      -
      Track
      -
      {config.track}
      -
      Classes
      -
      -
        - {#each config.classes as cls (cls.id)} -
      • {cls.name} — {FORMAT_LABELS[cls.format]}
      • - {/each} -
      -
      -
      - {#if problems.length > 0} -
        - {#each problems as p (p)}
      • {p}
      • {/each} -
      - {/if} - - {/if} -
      - - -
      - - diff --git a/frontend/apps/rd-console/tests/EventSetupWizard.test.ts b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts new file mode 100644 index 0000000..17be53d --- /dev/null +++ b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { Class, EventMeta, Pilot, Timer } from '@gridfpv/types'; +import EventSetupWizard from '../src/screens/EventSetupWizard.svelte'; +import { makeTestSession } from './support.js'; + +/** + * The setup wizard (race redesign Slice 7) is pure orchestration over the existing stage + * components/commands — so these tests cover the **stepper frame**: navigation (Back/Next), Skip, + * the clickable progress indicator, and the readiness ("ready to race?") summary read off the live + * event. The embedded stages have their own deep tests; here we only confirm the wizard surfaces + * each one (by its heading) and walks between them. + */ + +const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; +const MOCK: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready' +} as unknown as Timer; +const ACE: Pilot = { id: 'p1', callsign: 'Ace', source: 'Custom' } as unknown as Pilot; + +/** Inert directory reads so the embedded stages mount without throwing. */ +function impls() { + return { + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE]), + listTimersImpl: vi.fn(async () => [MOCK]), + listFormatsImpl: vi.fn(async () => ['timed_qual']), + listChannelsImpl: vi.fn(async () => []), + listHeatsImpl: vi.fn(async () => []) + }; +} + +/** An empty (freshly-created) event — nothing configured yet. */ +const EMPTY_EVENT: EventMeta = { + id: 'e1', + name: 'Spring Cup', + created_at: 0, + persistent: true, + timers: [], + roster: [], + classes: [] +}; + +/** A fully-configured event — every readiness check should be met. */ +const READY_EVENT: EventMeta = { + ...EMPTY_EVENT, + timers: ['mock'], + roster: ['p1'], + classes: ['c1'], + classes_membership: [{ class: 'c1', pilots: ['p1'] }], + rounds: [{ id: 'r1', label: 'Qual', classes: ['c1'] }] +} as unknown as EventMeta; + +describe('EventSetupWizard (guided first-pass stepper)', () => { + it('opens on the first step (Classes) and shows the event name', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + // The header names the event being set up and the first step is Classes. + expect(screen.getByText(/Set up · Spring Cup/)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Classes', level: 2 })).toBeInTheDocument(); + // It embeds the real EventClasses stage (its card heading), not a reimplementation. + expect( + await screen.findByRole('heading', { name: 'Classes for this event' }) + ).toBeInTheDocument(); + }); + + it('walks Next through the stages to Review, then Back', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + const nextBtn = () => screen.getByRole('button', { name: 'Next' }); + // Classes → Roster → Timer & channels → First round → Review. + await fireEvent.click(nextBtn()); + expect(await screen.findByRole('heading', { name: 'Present pilots' })).toBeInTheDocument(); + await fireEvent.click(nextBtn()); + expect( + await screen.findByRole('heading', { name: 'Timers for this event' }) + ).toBeInTheDocument(); + await fireEvent.click(nextBtn()); + expect(await screen.findByRole('heading', { name: 'Rounds', level: 3 })).toBeInTheDocument(); + await fireEvent.click(nextBtn()); + // Review: no Next, a Finish button instead. + expect(screen.getByRole('heading', { name: 'Ready to race?' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finish setup' })).toBeInTheDocument(); + + // Back returns to the First round step. + await fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('heading', { name: 'Rounds', level: 3 })).toBeInTheDocument(); + }); + + it('Skip advances a step just like Next (steps are optional)', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(await screen.findByRole('heading', { name: 'Present pilots' })).toBeInTheDocument(); + }); + + it('the progress indicator jumps directly to a step', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + const steps = screen.getByRole('list', { name: 'Setup steps' }); + await fireEvent.click(within(steps).getByRole('button', { name: /First round/ })); + expect(await screen.findByRole('heading', { name: 'Rounds', level: 3 })).toBeInTheDocument(); + }); + + it('Back is disabled on the first step', () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + expect((screen.getByRole('button', { name: 'Back' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('the readiness summary reports every item unmet for a fresh event', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + // Jump to Review via the progress indicator. + const steps = screen.getByRole('list', { name: 'Setup steps' }); + await fireEvent.click(within(steps).getByRole('button', { name: /Review/ })); + + const list = await screen.findByLabelText('Ready to race?'); + // None of the four checks is met; the verdict invites finishing anyway. + const unmet = within(list).getAllByText(/^0 (selected|placed|chosen|defined)$/); + expect(unmet).toHaveLength(4); + expect(screen.getByText(/the unmet items are optional/i)).toBeInTheDocument(); + }); + + it('the readiness summary reports ready when the event is fully configured', async () => { + const { session } = makeTestSession({ ...impls(), event: READY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + const steps = screen.getByRole('list', { name: 'Setup steps' }); + await fireEvent.click(within(steps).getByRole('button', { name: /Review/ })); + + await waitFor(() => + expect(screen.getByText('This event is ready to race.')).toBeInTheDocument() + ); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + expect(screen.getByText('1 placed')).toBeInTheDocument(); + expect(screen.getByText('1 chosen')).toBeInTheDocument(); + expect(screen.getByText('1 defined')).toBeInTheDocument(); + }); + + it('Finish closes the wizard and fires onclose', async () => { + const { session } = makeTestSession({ ...impls(), event: READY_EVENT }); + const onclose = vi.fn(); + const { rerender } = render(EventSetupWizard, { session, open: true, onclose }); + + const steps = screen.getByRole('list', { name: 'Setup steps' }); + await fireEvent.click(within(steps).getByRole('button', { name: /Review/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Finish setup' })); + + expect(onclose).toHaveBeenCalledOnce(); + // The bound `open` flips false → the overlay unmounts. + await rerender({ session, open: false, onclose }); + expect(screen.queryByRole('dialog', { name: 'Event setup wizard' })).not.toBeInTheDocument(); + }); + + it('renders nothing while closed', () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: false }); + expect(screen.queryByRole('dialog', { name: 'Event setup wizard' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/SetupWizard.test.ts b/frontend/apps/rd-console/tests/SetupWizard.test.ts deleted file mode 100644 index d3d0764..0000000 --- a/frontend/apps/rd-console/tests/SetupWizard.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { fireEvent } from '@testing-library/dom'; -import SetupWizard from '../src/screens/SetupWizard.svelte'; -import { emptyConfig, type EventConfig } from '../src/lib/setup.js'; - -describe('SetupWizard', () => { - it('walks the steps and commits a complete config', async () => { - const config: EventConfig = { - track: 'Main field', - classes: [{ id: 'open', name: 'Open', format: 'timed-qual', winCondition: 'BestLap' }] - }; - const oncommit = vi.fn(); - render(SetupWizard, { config, eventName: 'Spring Cup', oncommit }); - - // Step 0 shows the current event read-only — it never asks for the event again (#72 A1). - expect(screen.getByText('Spring Cup')).toBeInTheDocument(); - - // Jump to the Review step. - await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); - await fireEvent.click(screen.getByRole('button', { name: 'Save configuration' })); - - expect(oncommit).toHaveBeenCalledOnce(); - expect(oncommit.mock.calls[0][0]).toMatchObject({ track: 'Main field' }); - }); - - it('blocks finishing an incomplete config and lists the problems', async () => { - const oncommit = vi.fn(); - render(SetupWizard, { config: emptyConfig(), oncommit }); - - await fireEvent.click(screen.getByRole('button', { name: '5. Review' })); - const save = screen.getByRole('button', { name: 'Save configuration' }) as HTMLButtonElement; - expect(save.disabled).toBe(true); - expect(screen.getByText('Track needs a name.')).toBeInTheDocument(); - }); -}); diff --git a/frontend/apps/rd-console/tests/setup.test.ts b/frontend/apps/rd-console/tests/setup.test.ts deleted file mode 100644 index 4532bc0..0000000 --- a/frontend/apps/rd-console/tests/setup.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - defaultClass, - defaultWinCondition, - emptyConfig, - isConfigComplete, - scheduleHeatCommand, - validateConfig, - type EventConfig -} from '../src/lib/setup.js'; - -describe('setup config', () => { - it('seeds an empty config that is not yet complete', () => { - const c = emptyConfig(); - expect(isConfigComplete(c)).toBe(false); - expect(validateConfig(c).length).toBeGreaterThan(0); - }); - - it('picks a sensible default win condition per format', () => { - expect(defaultWinCondition('timed-qual')).toEqual({ Timed: { window_micros: 120_000_000 } }); - expect(defaultWinCondition('single-elim')).toEqual({ FirstToLaps: { n: 3 } }); - expect(defaultWinCondition('zippyq')).toEqual({ BestConsecutive: { n: 3 } }); - }); - - it('validates a complete config as ready', () => { - // The event id/name is no longer part of the wizard config (#72, Slice 1b A1) — the - // console is already inside an event; the wizard only needs a track + at least one class. - const c: EventConfig = { - track: 'Main field', - classes: [defaultClass('open', 'Open', 'timed-qual')] - }; - expect(validateConfig(c)).toEqual([]); - expect(isConfigComplete(c)).toBe(true); - }); - - it('flags each missing piece', () => { - const problems = validateConfig({ track: '', classes: [] }); - expect(problems).toContain('Track needs a name.'); - expect(problems).toContain('Add at least one class.'); - }); - - it('builds the one supported setup command: ScheduleHeat with a lineup', () => { - expect(scheduleHeatCommand('heat-1', ['ALICE', 'BOB'])).toEqual({ - ScheduleHeat: { heat: 'heat-1', lineup: ['ALICE', 'BOB'] } - }); - }); -}); diff --git a/frontend/e2e/wizard.spec.ts b/frontend/e2e/wizard.spec.ts new file mode 100644 index 0000000..a293e61 --- /dev/null +++ b/frontend/e2e/wizard.spec.ts @@ -0,0 +1,159 @@ +/** + * The setup wizard (race redesign Slice 7) — the deliverable proof for the guided first-pass over + * the stage-pages. + * + * A real click-through against a **real** Director (open / no token, full-trust by default): + * **create a new event** with "Set up event" ticked so the wizard opens, then walk its steps — + * pick a **class** (the built-in Open Class), **add a new pilot** and place them in the class, + * confirm the **Mock timer** is selected, **define a first round**, reach the **readiness summary**, + * and **finish**. Then assert the workspace's own stage-pages reflect everything the wizard set + * (same data, editable on the pages) — proving the wizard is pure orchestration over the existing + * stage commands, no separate config bag. + * + * Every step is a real click/input in headless chromium on the real `PUT /events/{id}/classes`, + * `POST /pilots` + `PUT roster`/`membership`, `PUT /events/{id}/timers`, `POST /events/{id}/rounds` + * paths — nothing mocked. Importing `test`/`expect` from `./observability.js` means a failure + * carries the full-stack dump (browser console, page errors, the Director's server log). + */ +import { expect, test } from './observability.js'; + +/** Get back to the home hub regardless of any active event a prior spec left (#90). */ +async function gotoHub(page: import('@playwright/test').Page) { + await page.goto('/'); + const eventsCard = page.getByRole('heading', { name: 'Events' }); + const liveNav = page.getByRole('button', { name: /Live control/ }); + await expect(eventsCard.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await liveNav.isVisible().catch(() => false)) { + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'Home' }) + .click(); + await expect(eventsCard).toBeVisible({ timeout: 15_000 }); + } +} + +test('RD creates an event, the wizard walks the stages, and the workspace reflects it all', async ({ + page +}) => { + const SUFFIX = Date.now(); + const EVENT = `E2E-Wizard-${SUFFIX}`; + const PILOT = `E2E-WizPilot-${SUFFIX}`; + const ROUND = `E2E-WizRound-${SUFFIX}`; + const shots = process.env.GRIDFPV_SHOTS; + + await gotoHub(page); + + // ── Events page → New event with "Set up event" ticked ────────────────────────────────────── + await page.getByRole('heading', { name: 'Events' }).click(); + await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ + timeout: 15_000 + }); + await page.getByRole('button', { name: '+ New event' }).first().click(); + const newForm = page.getByRole('form', { name: 'New event' }); + await expect(newForm).toBeVisible(); + await newForm.getByLabel('Event name').fill(EVENT); + // "Set up event" defaults on — confirm it's checked so the wizard launches after create. + const setupBox = page.getByRole('checkbox', { name: 'Set up event after creating' }); + await expect(setupBox).toBeChecked(); + await page.getByRole('button', { name: 'Create & enter' }).click(); + + // ── The wizard overlay opens on its first step (Classes) ──────────────────────────────────── + const wizard = page.getByRole('dialog', { name: 'Event setup wizard' }); + await expect(wizard).toBeVisible({ timeout: 15_000 }); + await expect(wizard.getByText(`Set up · ${EVENT}`)).toBeVisible(); + if (shots) await wizard.screenshot({ path: `${shots}/wizard-classes.png` }); + + // Step 1 — Classes: tick the built-in Open Class and save. + const classBox = wizard.getByRole('checkbox', { name: 'Select Open Class' }); + await expect(classBox).toBeVisible({ timeout: 15_000 }); + if (!(await classBox.isChecked())) await classBox.check(); + await wizard.getByRole('button', { name: 'Save classes' }).click(); + await expect(wizard.getByRole('button', { name: 'Save classes' })).toBeDisabled({ + timeout: 15_000 + }); + + // Step 2 — Roster: add a brand-new pilot, mark present, and place into the class. + await wizard.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(wizard.getByRole('heading', { name: 'Present pilots' })).toBeVisible(); + await wizard.getByRole('button', { name: '+ Add pilot' }).click(); + const addForm = page.getByRole('form', { name: 'Add pilot' }); + await expect(addForm).toBeVisible(); + await addForm.getByLabel('Callsign').fill(PILOT); + // `exact` to pick the dialog's submit, not the header's "+ Add pilot". + await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); + // The new pilot appears; mark them present (roster checkbox), then save the roster. + const rosterBox = wizard.getByRole('checkbox', { name: `Roster ${PILOT}` }); + await expect(rosterBox).toBeVisible({ timeout: 15_000 }); + if (!(await rosterBox.isChecked())) await rosterBox.check(); + await wizard.getByRole('button', { name: 'Save roster' }).click(); + await expect(wizard.getByRole('button', { name: 'Save roster' })).toBeDisabled({ + timeout: 15_000 + }); + // Place the pilot into the Open Class and save that class's membership. + const placeBox = wizard.getByRole('checkbox', { name: `Place ${PILOT} in Open Class` }); + await expect(placeBox).toBeVisible({ timeout: 15_000 }); + await placeBox.check(); + await wizard.getByRole('button', { name: 'Save membership' }).click(); + + // Step 3 — Timer & channels: the built-in Mock is selectable; ensure it's chosen. + await wizard.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(wizard.getByRole('heading', { name: 'Timers for this event' })).toBeVisible(); + const mockBox = wizard.getByRole('checkbox', { name: 'Use Mock' }); + await expect(mockBox).toBeVisible({ timeout: 15_000 }); + if (!(await mockBox.isChecked())) { + await mockBox.check(); + await wizard.getByRole('button', { name: 'Save selection' }).click(); + await expect(wizard.getByRole('button', { name: 'Save selection' })).toBeDisabled({ + timeout: 15_000 + }); + } + + // Step 4 — First round: the add-round form is pre-opened; define one and add it. + await wizard.getByRole('button', { name: 'Next', exact: true }).click(); + const roundForm = page.getByRole('form', { name: 'Add round' }); + await expect(roundForm).toBeVisible({ timeout: 15_000 }); + await roundForm.getByLabel('Label').fill(ROUND); + await roundForm.getByLabel('Eligible Open Class').check(); + await roundForm.getByLabel('Format').selectOption('timed_qual'); + await roundForm.getByLabel('Win condition').selectOption('BestLap'); + await page.getByRole('button', { name: 'Add round', exact: true }).click(); + await expect( + wizard.getByRole('list').getByRole('listitem').filter({ hasText: ROUND }) + ).toBeVisible({ timeout: 15_000 }); + + // Step 5 — Review: the readiness summary shows all four checks met, then finish. + await wizard.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(wizard.getByRole('heading', { name: 'Ready to race?' })).toBeVisible(); + await expect(wizard.getByText('This event is ready to race.')).toBeVisible({ timeout: 15_000 }); + if (shots) await wizard.screenshot({ path: `${shots}/wizard-review.png` }); + await wizard.getByRole('button', { name: 'Finish setup' }).click(); + await expect(wizard).toBeHidden({ timeout: 15_000 }); + + // ── The workspace's own stage-pages reflect everything the wizard set (editable on the pages) ── + const openTab = (name: string) => + page.getByRole('navigation', { name: 'Screens' }).getByRole('button', { name }).click(); + + // Classes tab: Open Class is selected for the event. + await openTab('Classes'); + await expect(page.getByRole('heading', { name: 'Classes for this event' })).toBeVisible({ + timeout: 15_000 + }); + await expect(page.getByRole('checkbox', { name: 'Select Open Class' })).toBeChecked(); + + // Roster tab: the pilot is present and placed in the class. + await openTab('Roster'); + await expect(page.getByRole('checkbox', { name: `Roster ${PILOT}` })).toBeChecked(); + await expect(page.getByRole('checkbox', { name: `Place ${PILOT} in Open Class` })).toBeChecked(); + + // Rounds & Heats tab: the round defined in the wizard lists. + await openTab('Rounds & Heats'); + await expect(page.getByRole('list').getByRole('listitem').filter({ hasText: ROUND })).toBeVisible( + { timeout: 15_000 } + ); + + // ── The wizard is re-runnable from the workspace header ─────────────────────────────────────── + await page.getByRole('button', { name: 'Setup wizard' }).click(); + await expect(page.getByRole('dialog', { name: 'Event setup wizard' })).toBeVisible({ + timeout: 15_000 + }); +}); From ab77e763df88d44dbc44cb2b11ef9bd546fa2001 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 13:35:24 +0000 Subject: [PATCH 117/362] Race: per-pilot channels + round channel-mode + mode-aware heat formation + format param schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 7a (backend). Adds the decided round-dependent channel model. Per-pilot channel on membership: ClassMembership.pilots is now Vec ({ pilot, channel? }) with a serde compat shim that still loads legacy Vec (bare-id elements) and restart round-trips. The membership route sets channels and validates each against the event's primary timer's available_channels — the pool may exceed node_count, so any channel in the pool is valid (node_count caps only pilots-per-heat). Round channel_mode (Static | PerHeat), #[serde(default)] = PerHeat: defaulted by format on create (timed_qual/round_robin -> Static; the elimination/multi-main/zippyq formats -> PerHeat), RD-overridable. Mode-aware FillRound: Static builds channel-balanced heats off each member's fixed channel (distinct channels per heat, <= node_count pilots; 20 members / 8 channels / 4 nodes -> heats of <=4 distinct-channel pilots, every member flies); a member with no channel is a typed MissingChannel error. PerHeat is the prior first-fit path, unchanged. Format param schemas: GET /formats now returns { name, params: [{ key, label, kind, options?, default? }] } from FormatRegistry::standard_schemas, declaring the params each generator reads. ts-rs'd the new types. Tests: membership channel set + primary-timer validation (incl. a channel beyond node_count is valid) + legacy load + restart round-trip; channel_mode default-by-format; static channel-balanced formation with channels > node_count; per-heat bracket still first-fits; param-schema route. race_flow e2e gains a static qual + keeps the per-heat bracket. Frontend build/check/lint/test/contract green; fixtures + contract cases updated. The combined Classes & Roster channel UI + the params dropdown are the Slice-7b follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/ChannelMode.ts | 21 + bindings/ClassMembership.ts | 16 +- bindings/FormatParam.ts | 34 ++ bindings/FormatSchema.ts | 16 + bindings/MemberSlot.ts | 28 ++ bindings/NewRoundReq.ts | 9 +- bindings/ParamKind.ts | 10 + bindings/RoundDef.ts | 12 +- bindings/SetClassMembershipRequest.ts | 12 +- bindings/UpdateRoundReq.ts | 8 +- crates/app/tests/race_flow.rs | 224 ++++++++- crates/engine/src/format.rs | 148 ++++++ crates/server/src/app.rs | 67 ++- crates/server/src/control_handler.rs | 66 ++- crates/server/src/events.rs | 347 +++++++++++++- crates/server/src/round_engine.rs | 445 +++++++++++++++++- .../rd-console/src/screens/EventRoster.svelte | 6 +- .../rd-console/src/screens/EventRounds.svelte | 3 +- .../src/screens/EventSetupWizard.svelte | 3 +- .../apps/rd-console/tests/EventRoster.test.ts | 4 +- .../apps/rd-console/tests/EventRounds.test.ts | 17 +- .../rd-console/tests/EventSetupWizard.test.ts | 2 +- .../apps/rd-console/tests/standings.test.ts | 3 +- frontend/contract/events.contract.ts | 124 ++++- .../packages/protocol-client/src/client.ts | 35 +- .../packages/protocol-client/src/index.ts | 1 + frontend/packages/types/src/generated.ts | 5 + 27 files changed, 1546 insertions(+), 120 deletions(-) create mode 100644 bindings/ChannelMode.ts create mode 100644 bindings/FormatParam.ts create mode 100644 bindings/FormatSchema.ts create mode 100644 bindings/MemberSlot.ts create mode 100644 bindings/ParamKind.ts 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/ClassMembership.ts b/bindings/ClassMembership.ts index e66d705..880cd94 100644 --- a/bindings/ClassMembership.ts +++ b/bindings/ClassMembership.ts @@ -1,6 +1,6 @@ // 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 { PilotId } from "./PilotId"; +import type { MemberSlot } from "./MemberSlot"; /** * One class's **membership** within an event (race redesign Slice 1a): the roster pilots that @@ -16,7 +16,15 @@ export type ClassMembership = { */ class: ClassId, /** - * The roster pilots racing this class, in selection order. Each is a directory pilot that is - * also on the event's [`roster`](EventMeta::roster). + * 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, }; +pilots: Array, }; 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/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/NewRoundReq.ts b/bindings/NewRoundReq.ts index c43f722..1d75b72 100644 --- a/bindings/NewRoundReq.ts +++ b/bindings/NewRoundReq.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; import type { ClassId } from "./ClassId"; import type { SeedingRule } from "./SeedingRule"; import type { WinCondition } from "./WinCondition"; @@ -36,4 +37,10 @@ win_condition: WinCondition, /** * How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. */ -seeding: SeedingRule, }; +seeding: SeedingRule, +/** + * 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, }; 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/RoundDef.ts b/bindings/RoundDef.ts index 4085650..a2d9842 100644 --- a/bindings/RoundDef.ts +++ b/bindings/RoundDef.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; import type { ClassId } from "./ClassId"; import type { RoundId } from "./RoundId"; import type { SeedingRule } from "./SeedingRule"; @@ -60,4 +61,13 @@ win_condition: WinCondition, * 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, }; +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, }; diff --git a/bindings/SetClassMembershipRequest.ts b/bindings/SetClassMembershipRequest.ts index 5051d4b..55f87df 100644 --- a/bindings/SetClassMembershipRequest.ts +++ b/bindings/SetClassMembershipRequest.ts @@ -1,5 +1,5 @@ // 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 { MemberSlot } from "./MemberSlot"; /** * The body of `PUT /events/{id}/classes/{class}/membership` — the roster pilot ids that race a @@ -9,6 +9,12 @@ import type { PilotId } from "./PilotId"; */ export type SetClassMembershipRequest = { /** - * The roster pilots that race this class, in selection order. Each must name a known pilot. + * 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. */ -pilot_ids: Array, }; +pilots: Array, }; diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts index a915c2b..b0a9438 100644 --- a/bindings/UpdateRoundReq.ts +++ b/bindings/UpdateRoundReq.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; import type { ClassId } from "./ClassId"; import type { SeedingRule } from "./SeedingRule"; import type { WinCondition } from "./WinCondition"; @@ -32,4 +33,9 @@ win_condition: WinCondition, /** * The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. */ -seeding: SeedingRule, }; +seeding: SeedingRule, +/** + * 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, }; diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index f3dc3d5..327197a 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -30,7 +30,9 @@ use gridfpv_events::{AdapterId, ClassId, Event, HeatId, RoundId}; use gridfpv_server::app::AppState; use gridfpv_server::classes::CreateClassRequest; use gridfpv_server::control::{Command, CommandAck}; -use gridfpv_server::events::{EventRegistry, NewRoundReq, RoundDef, SeedingRule}; +use gridfpv_server::events::{ + ChannelMode, EventRegistry, MemberSlot, NewRoundReq, RoundDef, SeedingRule, +}; use gridfpv_server::pilots::CreatePilotRequest; use gridfpv_server::scope::EventId; use gridfpv_server::timers::{MOCK_TIMER_ID, TimerId, TimerKind, UpdateTimerRequest}; @@ -286,7 +288,7 @@ async fn round_driven_mock_race_flow_e2e() { &app, &format!("/events/{}/classes/{}/membership", event.0, class_id.0), &token, - serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + serde_json::json!({ "pilots": pilots.iter().map(|p| p.0.clone()).collect::>() }), ) .await; @@ -302,6 +304,8 @@ async fn round_driven_mock_race_flow_e2e() { params: BTreeMap::from([("rounds".into(), "1".into())]), win_condition: gridfpv_engine::scoring::WinCondition::BestLap, seeding: SeedingRule::FromRoster, + // Per-heat: this flow asserts the whole-field heat + first-fit channel assignment. + channel_mode: Some(ChannelMode::PerHeat), }, ) .await; @@ -399,6 +403,8 @@ async fn round_driven_mock_race_flow_e2e() { source_round: qual.id.clone(), top_n: 2, }, + // single_elim defaults to PerHeat anyway; keep it explicit for the bracket carry. + channel_mode: Some(ChannelMode::PerHeat), }, ) .await; @@ -562,7 +568,7 @@ async fn fill_round_rejects_an_oversized_heat_e2e() { &app, &format!("/events/{}/classes/{}/membership", event.0, class_id.0), &token, - serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + serde_json::json!({ "pilots": pilots.iter().map(|p| p.0.clone()).collect::>() }), ) .await; let round: RoundDef = add_round( @@ -576,6 +582,8 @@ async fn fill_round_rejects_an_oversized_heat_e2e() { params: BTreeMap::from([("rounds".into(), "1".into())]), win_condition: gridfpv_engine::scoring::WinCondition::BestLap, seeding: SeedingRule::FromRoster, + // Per-heat: this flow asserts the node-cap rejection of an oversized whole-field heat. + channel_mode: Some(ChannelMode::PerHeat), }, ) .await; @@ -597,6 +605,216 @@ async fn fill_round_rejects_an_oversized_heat_e2e() { 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/{}/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: gridfpv_engine::scoring::WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + channel_mode: Some(ChannelMode::Static), + }, + ) + .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(), + }, + ) + .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::Scored } 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; + control_ok(&app, &event, &token, &Command::Arm { heat: heat.clone() }).await; + control_ok(&app, &event, &token, &Command::Start { 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::Finish { heat: heat.clone() }, + ) + .await; + control_ok(&app, &event, &token, &Command::Score { 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, + 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; diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index a91e48c..4f24faf 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -346,6 +346,95 @@ 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. + 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"`). + 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; @@ -402,6 +491,65 @@ impl FormatRegistry { self.ctors.keys().map(String::as_str).collect() } + /// The **param schema** for every production format (race redesign Slice 7a), in the same sorted + /// name order as [`names`](Self::names) over [`standard`](Self::standard). + /// + /// 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` (number, 3), `metric` (enum: best-lap/best-consecutive/most-laps). + /// - `round_robin`: `rounds` (3), `heat_size` (4), `metric` (enum: points/total-laps). + /// - `single_elim`: `heat_size` (number, 2). + /// - `double_elim`: `bracket_reset` (bool, on). + /// - `multi_main`: `main_size` (number, 4). + /// - `zippyq`: `rounds` (number, 0 — rounds are added on demand). + pub fn standard_schemas() -> Vec { + vec![ + FormatSchema { + name: "double_elim".into(), + params: vec![FormatParam::boolean("bracket_reset", "Bracket reset", "1")], + }, + FormatSchema { + name: "multi_main".into(), + params: vec![FormatParam::number("main_size", "Main size", "4")], + }, + FormatSchema { + name: "round_robin".into(), + params: vec![ + FormatParam::number("rounds", "Rounds", "3"), + FormatParam::number("heat_size", "Heat size", "4"), + FormatParam::enumerated( + "metric", + "Ranking metric", + &["points", "total-laps"], + "points", + ), + ], + }, + FormatSchema { + name: "single_elim".into(), + params: vec![FormatParam::number("heat_size", "Heat size", "2")], + }, + FormatSchema { + name: "timed_qual".into(), + params: vec![ + FormatParam::number("rounds", "Rounds", "3"), + FormatParam::enumerated( + "metric", + "Qualifying metric", + &["best-lap", "best-consecutive", "most-laps"], + "best-lap", + ), + ], + }, + FormatSchema { + name: "zippyq".into(), + params: vec![FormatParam::number("rounds", "Initial rounds", "0")], + }, + ] + } + /// Whether a format is registered under `name`. pub fn contains(&self, name: &str) -> bool { self.ctors.contains_key(name) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 953daa0..69eb6fa 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -79,7 +79,7 @@ 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; +use gridfpv_engine::format::{FormatRegistry, FormatSchema}; use gridfpv_engine::scoring::{WinCondition, score_events}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{LapList, lap_list_marshaled, registrations}; @@ -753,18 +753,15 @@ async fn list_classes(State(registry): State) -> Json> Json(registry.classes().list()) } -/// `GET /formats` — the valid **format names** (race redesign Slice 2b). +/// `GET /formats` — the valid **formats + their param schemas** (race redesign Slice 2b / 7a). /// -/// The single source of truth the Rounds UI's format dropdown reads: the production formats -/// registered in [`FormatRegistry::standard`], in sorted order. An open read (no token) — these -/// are static, compiled-in configuration, not per-event state, so no registry state is touched. -async fn list_formats() -> Json> { - let formats = FormatRegistry::standard() - .names() - .into_iter() - .map(String::from) - .collect(); - Json(formats) +/// 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). @@ -889,16 +886,50 @@ async fn set_class_membership( )); } let pilots = registry.pilots(); - for id in &body.pilot_ids { - if !pilots.exists(id) { + for slot in &body.pilots { + if !pilots.exists(&slot.pilot) { return Err(ProtocolError::new( ErrorCode::UnknownScope, - format!("no pilot with id {:?}", id.0), + format!("no pilot with id {:?}", 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 meta = registry.meta_of(&event_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + ) + })?; + 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.pilot_ids) + .set_class_membership(&event_id, class_id, body.pilots) .map_err(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; Ok(Json(meta)) } @@ -991,7 +1022,9 @@ 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(_) => ErrorCode::BadRequest, + FillError::EmptyField(_) | FillError::UnknownFormat(_) | FillError::MissingChannel(_) => { + ErrorCode::BadRequest + } }; ProtocolError::new(code, err.to_string()) } diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 3dc43c8..7753eb7 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -368,15 +368,38 @@ fn apply_fill_round( Err(err) => return CommandAck::failed(err), }; - match round_engine::fill_round(&meta, &round, &events) { - Ok(FillOutcome::Scheduled { heat, lineup }) => { + match round_engine::fill_round(&meta, ®istry.timers(), &round, &events) { + Ok(FillOutcome::Scheduled { + heat, + lineup, + frequencies: static_freqs, + }) => { let class = round_engine::round_class(&meta, &round); - // Assign channels from the event's effective primary timer's available set (race - // redesign Slice 4a): the heat-size cap (lineup ≤ node count) is enforced here, and the - // engine's first-fit allocator fills `frequencies` in seed order. A pure-sim event (no - // resolvable timer / no available channels) assigns none — an un-channelled heat. - let frequencies = - match round_engine::assign_for_event(&meta, ®istry.timers(), &lineup) { + // 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 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 CommandAck::failed(ProtocolError::new( @@ -384,7 +407,8 @@ fn apply_fill_round( err.to_string(), )); } - }; + }, + }; let event = Event::HeatScheduled { heat, lineup, @@ -843,7 +867,9 @@ mod tests { #[test] fn fill_round_schedules_a_tagged_heat_from_membership() { use crate::classes::CreateClassRequest; - use crate::events::{CreateEventRequest, NewRoundReq, SeedingRule}; + use crate::events::{ + ChannelMode, CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule, + }; use crate::pilots::CreatePilotRequest; use crate::scope::EventId; use gridfpv_engine::scoring::WinCondition; @@ -888,7 +914,11 @@ mod tests { .id; registry.set_classes(&event, vec![class.clone()]).unwrap(); registry - .set_class_membership(&event, class.clone(), pilots.clone()) + .set_class_membership( + &event, + class.clone(), + pilots.iter().cloned().map(MemberSlot::new).collect(), + ) .unwrap(); let round = registry .add_round( @@ -900,6 +930,8 @@ mod tests { params: BTreeMap::from([("rounds".into(), "1".into())]), win_condition: WinCondition::BestLap, seeding: SeedingRule::FromRoster, + // Per-heat: this test asserts the whole-field single heat (the bracket path). + channel_mode: Some(ChannelMode::PerHeat), }, ) .unwrap(); @@ -972,7 +1004,9 @@ mod tests { pilots: &[&str], ) -> (EventRegistry, EventId, gridfpv_events::RoundId) { use crate::classes::CreateClassRequest; - use crate::events::{CreateEventRequest, NewRoundReq, SeedingRule}; + use crate::events::{ + ChannelMode, CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule, + }; use crate::pilots::CreatePilotRequest; use gridfpv_engine::scoring::WinCondition; use std::collections::BTreeMap; @@ -1014,7 +1048,11 @@ mod tests { .id; registry.set_classes(&event, vec![class.clone()]).unwrap(); registry - .set_class_membership(&event, class.clone(), pilot_ids) + .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 @@ -1027,6 +1065,8 @@ mod tests { params: BTreeMap::from([("rounds".into(), "1".into())]), win_condition: WinCondition::BestLap, seeding: SeedingRule::FromRoster, + // Per-heat: this test asserts first-fit channel assignment from the timer pool. + channel_mode: Some(ChannelMode::PerHeat), }, ) .unwrap(); diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 6cbdf48..106d382 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -195,9 +195,122 @@ pub struct EventMeta { 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 is a directory pilot that is - /// also on the event's [`roster`](EventMeta::roster). - pub pilots: Vec, + /// 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* @@ -243,6 +356,53 @@ pub struct RoundDef { /// 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, +} + +/// 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). @@ -294,6 +454,12 @@ pub struct NewRoundReq { /// How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. #[serde(default)] pub seeding: SeedingRule, + /// 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 body of `PUT /events/{id}/rounds/{round}` — the editable fields of an existing round (race @@ -316,6 +482,11 @@ pub struct UpdateRoundReq { /// The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. #[serde(default)] pub seeding: SeedingRule, + /// 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, } impl EventMeta { @@ -386,8 +557,16 @@ pub struct SetEventClassesRequest { #[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 must name a known pilot. - pub pilot_ids: Vec, + /// 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. @@ -732,19 +911,21 @@ impl EventRegistry { /// Set the **per-class membership** for one class (race redesign Slice 1a), returning the /// event's updated [`EventMeta`]. /// - /// Replaces *that class's* pilot list wholesale with `pilot_ids` (other classes' memberships - /// are untouched). An empty `pilot_ids` 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 and each id names a directory pilot (the class/pilot directories are separate - /// authorities). The membership is recorded on the event's [`EventMeta`] and **written through** + /// 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, - pilot_ids: Vec, + pilots: Vec, ) -> Result { let mut reg = self.write(); let event = reg @@ -754,11 +935,11 @@ impl EventRegistry { // 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 !pilot_ids.is_empty() { - event.meta.classes_membership.push(ClassMembership { - class, - pilots: pilot_ids, - }); + if !pilots.is_empty() { + event + .meta + .classes_membership + .push(ClassMembership { class, pilots }); } let meta = event.meta.clone(); let data_dir = reg.data_dir.clone(); @@ -807,6 +988,12 @@ impl EventRegistry { } }; + // 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. + let channel_mode = req + .channel_mode + .unwrap_or_else(|| ChannelMode::default_for_format(&req.format)); let round = RoundDef { id: round_id, label: req.label, @@ -815,6 +1002,7 @@ impl EventRegistry { params: req.params, win_condition: req.win_condition, seeding: req.seeding, + channel_mode, }; event.meta.rounds.push(round.clone()); let meta = event.meta.clone(); @@ -857,6 +1045,11 @@ impl EventRegistry { Some(round_id), )?; + // 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. + let channel_mode = req + .channel_mode + .unwrap_or_else(|| ChannelMode::default_for_format(&req.format)); let round = RoundDef { id: round_id.clone(), label: req.label, @@ -865,6 +1058,7 @@ impl EventRegistry { params: req.params, win_condition: req.win_condition, seeding: req.seeding, + channel_mode, }; if let Some(slot) = event.meta.rounds.iter_mut().find(|r| &r.id == round_id) { *slot = round.clone(); @@ -1371,6 +1565,12 @@ mod tests { 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(), @@ -1716,18 +1916,18 @@ mod tests { // Set the Open class's membership. let meta = reg - .set_class_membership(&event.id, open.clone(), vec![a.clone(), b.clone()]) + .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, - vec![a.clone(), b.clone()] + 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(), vec![c.clone()]) + .set_class_membership(&event.id, spec.clone(), slots(vec![c.clone()])) .unwrap(); assert_eq!(meta.classes_membership.len(), 2); let open_entry = meta @@ -1735,11 +1935,11 @@ mod tests { .iter() .find(|m| m.class == open) .unwrap(); - assert_eq!(open_entry.pilots, vec![a.clone(), b.clone()]); + 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(), vec![a.clone()]) + .set_class_membership(&event.id, open.clone(), slots(vec![a.clone()])) .unwrap(); assert_eq!( meta.classes_membership @@ -1753,7 +1953,7 @@ mod tests { .iter() .find(|m| m.class == open) .unwrap(); - assert_eq!(open_entry.pilots, vec![a.clone()]); + assert_eq!(open_entry.pilots, slots(vec![a.clone()])); // An empty list clears the class's membership entry entirely. let meta = reg @@ -1769,6 +1969,66 @@ mod tests { ); } + #[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. @@ -1781,7 +2041,7 @@ mod tests { reg.set_class_membership( &created.id, ClassId("open-1".into()), - vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())], + slots(vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())]), ) .unwrap(); } @@ -1794,7 +2054,7 @@ mod tests { ); assert_eq!( restored.classes_membership[0].pilots, - vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())] + slots(vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())]) ); std::fs::remove_dir_all(&dir).ok(); } @@ -1834,6 +2094,7 @@ mod tests { params: BTreeMap::new(), win_condition: WinCondition::BestLap, seeding: SeedingRule::FromRoster, + channel_mode: None, } } @@ -1871,6 +2132,40 @@ mod tests { assert_eq!(reg.rounds_of(&event.id).unwrap().len(), 2); } + #[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 / round_robin → Static; the bracket formats → PerHeat (RD-overridable). + let cases = [ + ("timed_qual", ChannelMode::Static), + ("round_robin", ChannelMode::Static), + ("single_elim", ChannelMode::PerHeat), + ("double_elim", ChannelMode::PerHeat), + ("multi_main", ChannelMode::PerHeat), + ("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 update_and_remove_a_round() { let reg = EventRegistry::new(None).unwrap(); @@ -1896,6 +2191,7 @@ mod tests { params: BTreeMap::from([("advance".to_string(), "2".to_string())]), win_condition: WinCondition::FirstToLaps { n: 5 }, seeding: SeedingRule::FromRoster, + channel_mode: None, }, ) .unwrap(); @@ -2004,6 +2300,7 @@ mod tests { source_round: bracket.id.clone(), top_n: 2, }, + channel_mode: None, }, ); assert!(matches!(self_ref, Err(RoundError::Invalid(_)))); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 5135a71..a08c097 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -56,7 +56,7 @@ use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, Pass, RoundId, Sourc use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::events::{EventMeta, RoundDef, SeedingRule}; +use crate::events::{ChannelMode, EventMeta, RoundDef, SeedingRule}; use crate::timers::{Timer, TimerRegistry}; /// The hard guard against a generator that never completes (mirrors @@ -81,6 +81,13 @@ pub enum FillOutcome { 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 is complete — the generator returned /// [`GeneratorStep::Complete`](gridfpv_engine::format::GeneratorStep::Complete). No @@ -110,6 +117,10 @@ pub enum FillError { /// A [`SeedingRule::FromRanking`] names a `source_round` 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), } impl std::fmt::Display for FillError { @@ -129,6 +140,12 @@ impl std::fmt::Display for FillError { "seeding source round {id:?} does not exist in this event" ) } + FillError::MissingChannel(pilot) => { + write!( + f, + "static-channel round member {pilot:?} has no assigned channel" + ) + } } } } @@ -297,8 +314,8 @@ fn round_field( for class in &round.classes { if let Some(membership) = meta.classes_membership.iter().find(|m| &m.class == class) { - for pilot in &membership.pilots { - let competitor = CompetitorRef(pilot.0.clone()); + for slot in &membership.pilots { + let competitor = CompetitorRef(slot.pilot.0.clone()); if !field.contains(&competitor) { field.push(competitor); } @@ -613,10 +630,30 @@ fn best_lap_order(best: Option) -> i64 { /// `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())); @@ -645,6 +682,8 @@ pub fn fill_round( Some(plan) => Ok(FillOutcome::Scheduled { heat: plan.heat, lineup: plan.lineup, + // Per-heat: the handler assigns channels from the timer pool (first-fit). + frequencies: None, }), // Every plan the generator wants this step is already scheduled (the RD // re-issued FillRound before scoring the outstanding heat): nothing new to @@ -657,6 +696,189 @@ pub fn fill_round( } } +/// 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. + let format_rounds = static_round_count(round); + + let plans = channel_balanced_plan(round, &members, node_cap, format_rounds); + + // One heat per FillRound: emit the first plan not already scheduled (dedup like per-heat). + let already: Vec = scheduled_round_heats(events, &round.id); + 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), + }) + } + // Every planned channel-balanced heat is already scheduled → the static round is complete. + 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. +fn static_round_count(round: &RoundDef) -> usize { + let default = match round.format.as_str() { + "timed_qual" | "round_robin" => 3, + _ => 1, + }; + round + .params + .get("rounds") + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + .max(1) +} + +/// 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(); @@ -730,7 +952,9 @@ fn heat_passes(events: &[Event], heat: &HeatId) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::events::{ClassMembership, EventMeta, RoundDef, SeedingRule}; + use crate::events::{ + ChannelMode, ClassMembership, EventMeta, MemberSlot, RoundDef, SeedingRule, + }; use crate::scope::{ClassId as ScopeClassId, EventId, PilotId}; use gridfpv_engine::scoring::WinCondition; use gridfpv_events::{AdapterId, GateIndex, HeatTransition, SourceTime}; @@ -840,6 +1064,36 @@ mod tests { ); } + /// 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()), @@ -862,10 +1116,30 @@ mod tests { fn member(class: &str, pilots: &[&str]) -> ClassMembership { ClassMembership { class: ScopeClassId(class.into()), - pilots: pilots.iter().map(|p| PilotId((*p).into())).collect(), + 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()), @@ -875,6 +1149,7 @@ mod tests { params: BTreeMap::from([("rounds".into(), "1".into())]), win_condition: WinCondition::BestLap, seeding: SeedingRule::FromRoster, + channel_mode: ChannelMode::PerHeat, } } @@ -925,7 +1200,7 @@ mod tests { 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, &RoundId("q1".into()), &[]).unwrap(); + let outcome = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); match outcome { FillOutcome::Scheduled { lineup, .. } => { assert_eq!( @@ -946,7 +1221,7 @@ mod tests { let round = qual_round("q1", "open"); let meta = meta_with(vec![round], vec![]); assert!(matches!( - fill_round(&meta, &RoundId("q1".into()), &[]), + fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]), Err(FillError::EmptyField(_)) )); } @@ -958,7 +1233,7 @@ mod tests { let meta = meta_with(vec![round], vec![member("open", &["A", "B"])]); // The first heat the generator emits is `round-1`. - let first = fill_round(&meta, &RoundId("q1".into()), &[]).unwrap(); + 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:?}"), @@ -977,7 +1252,7 @@ mod tests { )); // Now the round is complete (1 round configured, 1 scored). - let next = fill_round(&meta, &RoundId("q1".into()), &log).unwrap(); + 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. @@ -1002,6 +1277,7 @@ mod tests { source_round: RoundId("q1".into()), top_n: 2, }, + channel_mode: ChannelMode::PerHeat, }; let meta = meta_with( vec![qual, bracket], @@ -1009,7 +1285,7 @@ mod tests { ); // Run the qual heat to Scored: A fastest, then B, C, D. - let first = fill_round(&meta, &RoundId("q1".into()), &[]).unwrap(); + let first = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); let qheat = match first { FillOutcome::Scheduled { heat, .. } => heat.0, other => panic!("expected scheduled, got {other:?}"), @@ -1030,7 +1306,7 @@ mod tests { )); // FillRound the bracket: the field is the top-2 of the qual ranking — A and B. - let outcome = fill_round(&meta, &RoundId("b1".into()), &log).unwrap(); + let outcome = fill_round(&meta, &no_timers(), &RoundId("b1".into()), &log).unwrap(); match outcome { FillOutcome::Scheduled { lineup, .. } => { assert_eq!( @@ -1043,6 +1319,145 @@ mod tests { } } + // --- 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, + } + } + + #[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, + }); + 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, @@ -1160,8 +1575,8 @@ mod tests { 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, &RoundId("q1".into()), &[]).unwrap(); - let twice = fill_round(&meta, &RoundId("q1".into()), &[]).unwrap(); + 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); } @@ -1181,9 +1596,9 @@ mod tests { let mut log: Vec = Vec::new(); let mut outcomes = Vec::new(); for _ in 0..4 { - let outcome = fill_round(&meta, &RoundId("q1".into()), &log).unwrap(); + let outcome = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &log).unwrap(); outcomes.push(outcome.clone()); - if let FillOutcome::Scheduled { heat, lineup } = outcome { + if let FillOutcome::Scheduled { heat, lineup, .. } = outcome { let heat_id = heat.0.clone(); log.push(Event::HeatScheduled { heat, diff --git a/frontend/apps/rd-console/src/screens/EventRoster.svelte b/frontend/apps/rd-console/src/screens/EventRoster.svelte index 34ed1ee..15291b8 100644 --- a/frontend/apps/rd-console/src/screens/EventRoster.svelte +++ b/frontend/apps/rd-console/src/screens/EventRoster.svelte @@ -146,7 +146,9 @@ const savedMembership = $derived.by(() => { const map = new Map>(); for (const m of session.currentEvent?.classes_membership ?? []) { - map.set(m.class, new Set(m.pilots)); + // Membership entries are member **slots** (`{ pilot, channel? }`) — Slice 7a; key on the + // pilot id (the channel picker is the Classes & Roster follow-up). + map.set(m.class, new Set(m.pilots.map((s) => s.pilot))); } return map; }); @@ -156,7 +158,7 @@ let membership = $state>>(new Map()); const membershipKey = $derived( (session.currentEvent?.classes_membership ?? []) - .map((m) => `${m.class}:${[...m.pilots].join('.')}`) + .map((m) => `${m.class}:${m.pilots.map((s) => s.pilot).join('.')}`) .join('|') ); let lastMembershipKey = $state(''); diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index eb229ed..b0c8197 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -259,7 +259,8 @@ const out: PilotId[] = []; for (const cls of round.classes) { const m = membership.find((mm) => mm.class === cls); - for (const pid of m?.pilots ?? []) if (!out.includes(pid)) out.push(pid); + // Membership entries are member slots (`{ pilot, channel? }`) — Slice 7a; field is the pilots. + for (const s of m?.pilots ?? []) if (!out.includes(s.pilot)) out.push(s.pilot); } return out; } diff --git a/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte index 1632a57..b8413c5 100644 --- a/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte +++ b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte @@ -123,7 +123,8 @@ const classCount = $derived(ev?.classes?.length ?? 0); const memberCount = $derived( (ev?.classes_membership ?? []).reduce((set, m) => { - for (const p of m.pilots) set.add(p); + // Membership entries are member slots (`{ pilot, channel? }`) — Slice 7a; count distinct pilots. + for (const s of m.pilots) set.add(s.pilot); return set; }, new Set()).size ); diff --git a/frontend/apps/rd-console/tests/EventRoster.test.ts b/frontend/apps/rd-console/tests/EventRoster.test.ts index 9881951..6946547 100644 --- a/frontend/apps/rd-console/tests/EventRoster.test.ts +++ b/frontend/apps/rd-console/tests/EventRoster.test.ts @@ -126,7 +126,7 @@ describe('EventRoster — per-class membership', () => { const listClassesImpl = vi.fn(async () => [OPEN]); const setClassMembershipImpl = vi.fn(async () => ({ ...EV_CLASS, - classes_membership: [{ class: 'open', pilots: ['p1'] }] + classes_membership: [{ class: 'open', pilots: [{ pilot: 'p1' }] }] })); const { session } = makeTestSession({ listPilotsImpl, @@ -172,7 +172,7 @@ describe('EventRoster — per-class membership', () => { const { session } = makeTestSession({ listPilotsImpl, listClassesImpl, - event: { ...EV_CLASS, classes_membership: [{ class: 'open', pilots: ['p2'] }] } + event: { ...EV_CLASS, classes_membership: [{ class: 'open', pilots: [{ pilot: 'p2' }] }] } }); render(EventRoster, { session }); diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 7323dc4..cc0c762 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -25,7 +25,8 @@ const QUAL: RoundDef = { format: 'timed_qual', params: {}, win_condition: { Timed: { window_micros: 120_000_000 } }, - seeding: 'FromRoster' + seeding: 'FromRoster', + channel_mode: 'Static' }; /** An event selecting both classes, with one existing round. */ @@ -81,7 +82,8 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { format: 'zippyq', params: {}, win_condition: 'BestLap', - seeding: 'FromRoster' + seeding: 'FromRoster', + channel_mode: 'PerHeat' }; const createRoundImpl = vi.fn(async (_b, _e, _req) => created); const { session } = makeTestSession({ @@ -193,7 +195,7 @@ describe('EventRounds (Heats — fill round, heats list, manual build)', () => { const EVENT_WITH_MEMBERS: EventMeta = { ...EVENT, roster: ['p1', 'p2'], - classes_membership: [{ class: 'c1', pilots: ['p1', 'p2'] }] + classes_membership: [{ class: 'c1', pilots: [{ pilot: 'p1' }, { pilot: 'p2' }] }] }; function heatsImpls(heats: HeatSummary[] = []) { @@ -326,7 +328,7 @@ describe('EventRounds (per-round standings + advance-to-bracket — Slice 5/6b)' const EVENT_WITH_MEMBERS: EventMeta = { ...EVENT, roster: ['p1', 'p2'], - classes_membership: [{ class: 'c1', pilots: ['p1', 'p2'] }] + classes_membership: [{ class: 'c1', pilots: [{ pilot: 'p1' }, { pilot: 'p2' }] }] }; function baseHeatsImpls() { @@ -378,7 +380,9 @@ describe('EventRounds (per-round standings + advance-to-bracket — Slice 5/6b)' const EVENT_3: EventMeta = { ...EVENT_WITH_MEMBERS, roster: ['p1', 'p2', 'p3'], - classes_membership: [{ class: 'c1', pilots: ['p1', 'p2', 'p3'] }] + classes_membership: [ + { class: 'c1', pilots: [{ pilot: 'p1' }, { pilot: 'p2' }, { pilot: 'p3' }] } + ] }; const created: RoundDef = { id: 'r2', @@ -387,7 +391,8 @@ describe('EventRounds (per-round standings + advance-to-bracket — Slice 5/6b)' format: 'single_elim', params: {}, win_condition: QUAL.win_condition, - seeding: { FromRanking: { source_round: 'r1', top_n: 2 } } + seeding: { FromRanking: { source_round: 'r1', top_n: 2 } }, + channel_mode: 'PerHeat' }; const createRoundImpl = vi.fn(async (_b, _e, _req) => created); const { session, sendSpy } = makeTestSession({ diff --git a/frontend/apps/rd-console/tests/EventSetupWizard.test.ts b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts index 17be53d..f06bc5a 100644 --- a/frontend/apps/rd-console/tests/EventSetupWizard.test.ts +++ b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts @@ -51,7 +51,7 @@ const READY_EVENT: EventMeta = { timers: ['mock'], roster: ['p1'], classes: ['c1'], - classes_membership: [{ class: 'c1', pilots: ['p1'] }], + classes_membership: [{ class: 'c1', pilots: [{ pilot: 'p1' }] }], rounds: [{ id: 'r1', label: 'Qual', classes: ['c1'] }] } as unknown as EventMeta; diff --git a/frontend/apps/rd-console/tests/standings.test.ts b/frontend/apps/rd-console/tests/standings.test.ts index 691d96e..dd049ed 100644 --- a/frontend/apps/rd-console/tests/standings.test.ts +++ b/frontend/apps/rd-console/tests/standings.test.ts @@ -32,7 +32,8 @@ describe('advanceRoundReq — the seeded single_elim payload', () => { format: 'timed_qual', params: { rounds: '2' }, win_condition: { Timed: { window_micros: 120_000_000 } }, - seeding: 'FromRoster' + seeding: 'FromRoster', + channel_mode: 'Static' }; it('builds a single_elim round seeded FromRanking from the source round, carrying its classes + win condition', () => { diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 9ca0aad..cb39ab5 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -21,7 +21,15 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Class, Command, EventMeta, Pilot, RoundDef, Timer } from '@gridfpv/types'; +import type { + Class, + Command, + EventMeta, + FormatSchema, + Pilot, + RoundDef, + Timer +} from '@gridfpv/types'; import { type Director } from '../test-harness/director.ts'; import { eventRoot, startContractDirector } from './harness.ts'; @@ -910,18 +918,22 @@ describe('seam 12: application-level classes + per-event selection (#84)', () => * - an empty `pilot_ids` clears the class's membership entry. */ describe('race Slice 1a: per-class membership', () => { - /** `PUT /events/{id}/classes/{classId}/membership` with `{ pilot_ids }` + optional token. */ + /** + * `PUT /events/{id}/classes/{classId}/membership` with `{ pilots }` + optional token. Each element + * may be a bare pilot-id string (the legacy/channel-less shape, accepted by the server's serde + * shim) or a full `{ pilot, channel? }` slot (race redesign Slice 7a). + */ async function setMembership( eventId: string, classId: string, - pilotIds: string[], + pilots: Array, token?: string ): Promise<{ status: number; body: unknown }> { const headers: Record = { 'Content-Type': 'application/json' }; if (token !== undefined) headers.Authorization = `Bearer ${token}`; const res = await fetch( `${eventRoot(director.baseUrl, eventId)}/classes/${classId}/membership`, - { method: 'PUT', headers, body: JSON.stringify({ pilot_ids: pilotIds }) } + { method: 'PUT', headers, body: JSON.stringify({ pilots }) } ); let parsed: unknown; try { @@ -952,17 +964,19 @@ describe('race Slice 1a: per-class membership', () => { const a = await makePilot('Member A'); const b = await makePilot('Member B'); - // Set the Open built-in class's membership. + // Set the Open built-in class's membership (bare ids → channel-less slots, via the serde shim). const ok = await setMembership(event.id, 'mgp-open', [a, b], TOKEN); expect(ok.status).toBe(200); const meta = ok.body as EventMeta; const entry = (meta.classes_membership ?? []).find((m) => m.class === 'mgp-open'); - expect(entry?.pilots).toEqual([a, b]); + // The wire shape is now member slots: `{ pilot, channel? }` (race redesign Slice 7a). + expect(entry?.pilots.map((s) => s.pilot)).toEqual([a, b]); + expect(entry?.pilots.every((s) => s.channel === undefined)).toBe(true); // Replacing that class's list is wholesale. const replaced = (await setMembership(event.id, 'mgp-open', [a], TOKEN)).body as EventMeta; const replacedEntry = (replaced.classes_membership ?? []).find((m) => m.class === 'mgp-open'); - expect(replacedEntry?.pilots).toEqual([a]); + expect(replacedEntry?.pilots.map((s) => s.pilot)).toEqual([a]); // An empty list clears the class's entry. const cleared = (await setMembership(event.id, 'mgp-open', [], TOKEN)).body as EventMeta; @@ -981,6 +995,38 @@ describe('race Slice 1a: per-class membership', () => { // RD-gated: no token → 401. expect((await setMembership(event.id, 'mgp-open', [a])).status).toBe(401); }); + + it('per-pilot channels (Slice 7a) are set when valid and rejected when not in the primary timer pool', async () => { + const event = (await createEvent('Channel Membership', TOKEN)).body as EventMeta; + const a = await makePilot('Chan A'); + const b = await makePilot('Chan B'); + + // The event's default primary is the built-in Mock, whose available channels include Raceband + // R1/R2 (5658 / 5695). Assigning those is accepted and round-trips on the slot's `channel`. + const ok = await setMembership( + event.id, + 'mgp-open', + [ + { pilot: a, channel: 5658 }, + { pilot: b, channel: 5695 } + ], + TOKEN + ); + expect(ok.status).toBe(200); + const entry = ((ok.body as EventMeta).classes_membership ?? []).find( + (m) => m.class === 'mgp-open' + ); + expect(entry?.pilots).toEqual([ + { pilot: a, channel: 5658 }, + { pilot: b, channel: 5695 } + ]); + + // A channel NOT in the primary timer's available pool → 400 BadRequest (node_count does not + // cap the channel set; only the pool membership is validated). + const bad = await setMembership(event.id, 'mgp-open', [{ pilot: a, channel: 1234 }], TOKEN); + expect(bad.status).toBe(400); + expect((bad.body as { code?: string }).code).toBe('BadRequest'); + }); }); /** @@ -1093,6 +1139,37 @@ describe('race Slice 2a: rounds', () => { expect(round.label).toBe('Qualifying R1'); expect(round.format).toBe('timed_qual'); expect(round.seeding).toBe('FromRoster'); // default + // Race redesign Slice 7a: channel_mode defaults by format — timed_qual → 'Static'. + expect(round.channel_mode).toBe('Static'); + + // A bracket format defaults to 'PerHeat'; an explicit channel_mode overrides the default. + const bracket = ( + await addRound( + event.id, + { + label: 'Bracket', + classes: ['mgp-open'], + format: 'single_elim', + win_condition: 'BestLap' + }, + TOKEN + ) + ).body as RoundDef; + expect(bracket.channel_mode).toBe('PerHeat'); + const forced = ( + await addRound( + event.id, + { + label: 'Forced', + classes: ['mgp-open'], + format: 'timed_qual', + channel_mode: 'PerHeat', + win_condition: 'BestLap' + }, + TOKEN + ) + ).body as RoundDef; + expect(forced.channel_mode).toBe('PerHeat'); // The round is recorded on the event meta (re-create returns the current snapshot via list). const list = (await fetch(`${director.baseUrl}/events`).then((r) => r.json())) as EventMeta[]; @@ -1165,14 +1242,15 @@ describe('race Slice 2a: rounds', () => { expect((badEvent.body as { code?: string }).code).toBe('UnknownScope'); }); - it('GET /formats is an open read of the standard format names (the round dropdown source)', async () => { - // The Rounds UI sources its format dropdown here rather than hard-coding the list — the single - // source of truth is the engine's `FormatRegistry::standard()`. An open read (no token). + it('GET /formats is an open read of the standard formats + their param schemas (the round dropdown / params editor source)', async () => { + // The Rounds UI sources its format dropdown AND per-format params editor here rather than + // hard-coding the list — the single source of truth is the engine's `FormatRegistry`. An open + // read (no token). Race redesign Slice 7a: each entry is `{ name, params: [...] }`. const res = await fetch(`${director.baseUrl}/formats`); expect(res.status).toBe(200); - const names = (await res.json()) as string[]; - // The 6 production formats, in sorted order (the same set `POST /rounds` validates against). - expect(names).toEqual([ + const schemas = (await res.json()) as FormatSchema[]; + // The 6 production formats, in sorted name order (the same set `POST /rounds` validates against). + expect(schemas.map((s) => s.name)).toEqual([ 'double_elim', 'multi_main', 'round_robin', @@ -1180,6 +1258,17 @@ describe('race Slice 2a: rounds', () => { 'timed_qual', 'zippyq' ]); + // timed_qual declares `rounds` (number, default 3) and an enum `metric` with its options. + const tq = schemas.find((s) => s.name === 'timed_qual')!; + const rounds = tq.params.find((p) => p.key === 'rounds')!; + expect(rounds.kind).toBe('number'); + expect(rounds.default).toBe('3'); + const metric = tq.params.find((p) => p.key === 'metric')!; + expect(metric.kind).toBe('enum'); + expect(metric.options).toContain('best-lap'); + // double_elim declares a bool `bracket_reset`. + const de = schemas.find((s) => s.name === 'double_elim')!; + expect(de.params.find((p) => p.key === 'bracket_reset')?.kind).toBe('bool'); }); }); @@ -1244,7 +1333,9 @@ describe('race Slice 3a: FillRound (round-driven engine)', () => { await fetch(`${eventRoot(director.baseUrl, event.id)}/classes/mgp-open/membership`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, - body: JSON.stringify({ pilot_ids: pilotIds }) + // Bare pilot-id elements (channel-less slots, via the serde shim) — this seam exercises the + // per-heat path (whole-field heat + first-fit channels), so the round is forced PerHeat. + body: JSON.stringify({ pilots: pilotIds }) }); const created = await fetch(`${eventRoot(director.baseUrl, event.id)}/rounds`, { method: 'POST', @@ -1254,7 +1345,10 @@ describe('race Slice 3a: FillRound (round-driven engine)', () => { classes: ['mgp-open'], format: 'timed_qual', params: { rounds: '1' }, - win_condition: 'BestLap' + win_condition: 'BestLap', + // timed_qual defaults to Static (Slice 7a); these FillRound seams assert the per-heat + // whole-field path, so force PerHeat (the static path is covered by the app-level e2e). + channel_mode: 'PerHeat' }) }); const round = (await created.json()) as RoundDef; diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 0a833b2..f7f5006 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -31,6 +31,7 @@ import type { Cursor, EventId, EventMeta, + FormatSchema, HeatSummary, NewRoundReq, Pilot, @@ -788,7 +789,10 @@ export async function setClassMembership( { method: 'PUT', headers, - body: JSON.stringify({ pilot_ids: pilotIds }) + // The wire shape carries member **slots** (`{ pilot, channel? }`) — race redesign Slice 7a. + // The server accepts a bare pilot-id element too (legacy shim), so passing plain ids here + // sets a channel-less membership; per-pilot channels are the Classes & Roster follow-up. + body: JSON.stringify({ pilots: pilotIds }) } ); if (!resp.ok) @@ -799,21 +803,36 @@ export async function setClassMembership( } /** - * List the valid **format names** (`GET /formats`) — race redesign Slice 2b. An open read (no - * token): the production formats registered in the engine's `FormatRegistry::standard()`, the - * single source of truth the Rounds UI's format dropdown reads. Resolves to the names in sorted - * order, or rejects on a non-2xx / transport failure. + * List the valid **formats + their param schemas** (`GET /formats`) — race redesign Slice 2b / 7a. + * An open read (no token): each production format (`FormatRegistry::standard()`) with the param + * schema its generator reads (`{ name, params: [{ key, label, kind, options?, default? }] }`) — the + * single source of truth the Rounds UI reads for the format dropdown and a per-format params editor. + * Resolves to the schemas in sorted name order, or rejects on a non-2xx / transport failure. */ -export async function listFormats( +export async function listFormatSchemas( baseUrl: string, options: { token?: string; fetch?: FetchLike } = {} -): Promise { +): Promise { const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); const headers: Record = { Accept: 'application/json' }; if (options.token) headers.Authorization = `Bearer ${options.token}`; const resp = await fetchImpl(`${trimSlash(baseUrl)}/formats`, { headers }); if (!resp.ok) throw new Error(`GET /formats failed: HTTP ${resp.status}`); - return (await resp.json()) as string[]; + return (await resp.json()) as FormatSchema[]; +} + +/** + * List the valid **format names** (`GET /formats`) — the names-only convenience over + * {@link listFormatSchemas} the Rounds UI's format dropdown reads. Resolves to the schema names in + * sorted order, or rejects on a non-2xx / transport failure. The full per-format param schema (for + * a params editor) is {@link listFormatSchemas}. + */ +export async function listFormats( + baseUrl: string, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const schemas = await listFormatSchemas(baseUrl, options); + return schemas.map((s) => s.name); } /** diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 7782f3b..dc79109 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -38,6 +38,7 @@ export { setEventClasses, setClassMembership, listFormats, + listFormatSchemas, listChannels, createRound, updateRound, diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index d159198..bd09d42 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -31,6 +31,7 @@ export type * from '@bindings/Change'; export type * from '@bindings/ChangeEnvelope'; export type * from '@bindings/ChannelCapability'; export type * from '@bindings/ChannelCatalogEntry'; +export type * from '@bindings/ChannelMode'; export type * from '@bindings/Class'; export type * from '@bindings/ClassId'; export type * from '@bindings/ClassMembership'; @@ -54,6 +55,8 @@ export type * from '@bindings/Event'; export type * from '@bindings/EventId'; export type * from '@bindings/EventMeta'; export type * from '@bindings/EventOutcome'; +export type * from '@bindings/FormatParam'; +export type * from '@bindings/FormatSchema'; export type * from '@bindings/GateIndex'; export type * from '@bindings/HeatId'; export type * from '@bindings/HeatPhase'; @@ -66,9 +69,11 @@ export type * from '@bindings/Lap'; export type * from '@bindings/LapList'; export type * from '@bindings/LiveRaceState'; export type * from '@bindings/LogRef'; +export type * from '@bindings/MemberSlot'; export type * from '@bindings/Metric'; export type * from '@bindings/NewRoundReq'; export type * from '@bindings/OptionalEdit'; +export type * from '@bindings/ParamKind'; export type * from '@bindings/Pass'; export type * from '@bindings/Penalty'; export type * from '@bindings/Pilot'; From e9187364e1a6fdcaf6debced88b1e8b382f88520 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 14:13:25 +0000 Subject: [PATCH 118/362] Race: combine Classes & Roster (per-pilot channels) + guided round params + channel-mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the Classes + Roster workspace tabs into one "Classes & Roster" stage (EventClassesRoster.svelte): pick the event's classes on top, then the roster + per-class placement below, keeping the embedded ClassManager/PilotManager so the RD never leaves the event to add one. - Single-class auto-fill: with exactly one class selected, every present pilot is auto-placed (kept in sync as the roster changes, incl. live); ≥2 classes show the per-class placement checkboxes. - Per-pilot channel selector sourced from the event's primary timer's available_channels (resolved via primaryTimerId → selectedTimers → the GET /channels catalog for band+channel·MHz labels); saving sends MemberSlot { pilot, channel }. The old (adapter,competitor) bind form is folded in — the channel IS the static binding; a source picker only surfaces with >1 timing source. - Guided round params: the free-text params editor becomes "+ Add param" → a dropdown of the chosen format's schema params (GET /formats via listFormatSchemas), each value typed by kind (number / enum select / bool toggle), seeded from default; remove to unset. - Round channel-mode toggle (Static | Per-heat), defaulted from the round's channel_mode. route.ts/App.svelte: replace the roster+classes tabs with one classes-roster tab (hash-routing + Alt-keys kept consistent). The setup wizard's Classes + Roster steps collapse into one combined step. protocol-client setClassMembership now accepts MemberSlot[]; session gains listFormatSchemas. Gates: build/check/lint clean, 241 unit + 79 contract pass, cargo xtask ci green, all 16 browser e2e pass. (Two stale e2e setups using the pre-#154 `pilot_ids` membership key + the now-Static-by-default timed_qual were fixed to `pilots` + explicit channel_mode.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/App.svelte | 31 +- frontend/apps/rd-console/src/lib/route.ts | 8 +- .../apps/rd-console/src/lib/session.svelte.ts | 24 +- .../src/screens/EventClasses.svelte | 192 ----- .../src/screens/EventClassesRoster.svelte | 752 ++++++++++++++++++ .../rd-console/src/screens/EventRoster.svelte | 621 --------------- .../rd-console/src/screens/EventRounds.svelte | 209 ++++- .../src/screens/EventSetupWizard.svelte | 23 +- .../rd-console/tests/EventClasses.test.ts | 93 --- .../tests/EventClassesRoster.test.ts | 153 ++++ .../apps/rd-console/tests/EventRoster.test.ts | 243 ------ .../apps/rd-console/tests/EventRounds.test.ts | 135 +++- .../rd-console/tests/EventSetupWizard.test.ts | 18 +- frontend/apps/rd-console/tests/route.test.ts | 13 +- frontend/apps/rd-console/tests/support.ts | 5 + frontend/e2e/classes.spec.ts | 4 +- frontend/e2e/roster.spec.ts | 79 +- frontend/e2e/rounds.spec.ts | 112 ++- frontend/e2e/routing.spec.ts | 20 +- frontend/e2e/wizard.spec.ts | 50 +- .../packages/protocol-client/src/client.ts | 23 +- 21 files changed, 1476 insertions(+), 1332 deletions(-) delete mode 100644 frontend/apps/rd-console/src/screens/EventClasses.svelte create mode 100644 frontend/apps/rd-console/src/screens/EventClassesRoster.svelte delete mode 100644 frontend/apps/rd-console/src/screens/EventRoster.svelte delete mode 100644 frontend/apps/rd-console/tests/EventClasses.test.ts create mode 100644 frontend/apps/rd-console/tests/EventClassesRoster.test.ts delete mode 100644 frontend/apps/rd-console/tests/EventRoster.test.ts diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index d64655f..46cdc0f 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -36,8 +36,7 @@ import TokenDialog from './screens/TokenDialog.svelte'; import EventSetupWizard from './screens/EventSetupWizard.svelte'; import EventTimers from './screens/EventTimers.svelte'; - import EventRoster from './screens/EventRoster.svelte'; - import EventClasses from './screens/EventClasses.svelte'; + import EventClassesRoster from './screens/EventClassesRoster.svelte'; import EventRounds from './screens/EventRounds.svelte'; import LiveRaceControl from './screens/LiveRaceControl.svelte'; import Marshaling from './screens/Marshaling.svelte'; @@ -123,33 +122,27 @@ } }); - type ScreenId = 'timers' | 'roster' | 'classes' | 'rounds' | 'live' | 'marshaling' | 'results'; + type ScreenId = 'timers' | 'classes-roster' | 'rounds' | 'live' | 'marshaling' | 'results'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ { - id: 'roster', - label: 'Roster', + id: 'classes-roster', + label: 'Classes & Roster', key: '1', icon: 'M16 11V7a4 4 0 1 0-8 0v4M5 11h14v9H5z' }, - { - id: 'classes', - label: 'Classes', - key: '2', - icon: 'M4 6h16M4 12h10M4 18h7M18 14l2 2 3-4' - }, { id: 'rounds', label: 'Rounds & Heats', - key: '3', + key: '2', icon: 'M4 5h16M4 12h16M4 19h16M9 5v14' }, - { id: 'live', label: 'Live control', key: '4', icon: 'M5 3l14 9-14 9V3z' }, - { id: 'marshaling', label: 'Marshaling', key: '5', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, - { id: 'results', label: 'Results', key: '6', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, + { id: 'live', label: 'Live control', key: '3', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'marshaling', label: 'Marshaling', key: '4', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, + { id: 'results', label: 'Results', key: '5', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, { id: 'timers', label: 'Timers', - key: '7', + key: '6', icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z' } ]; @@ -369,10 +362,8 @@
      {#if active === 'timers'} - {:else if active === 'roster'} - - {:else if active === 'classes'} - + {:else if active === 'classes-roster'} + {:else if active === 'rounds'} {:else if active === 'live'} diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index 62d0a48..93efdb0 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -17,7 +17,7 @@ * - `#/events` → the Events page (the picker) * - `#/timers` → the Timers page * - `#/event/` → the in-event workspace on `` - * (tab ∈ roster | classes | rounds | live | marshaling | results | timers) + * (tab ∈ classes-roster | rounds | live | marshaling | results | timers) * * The active event itself is **app-wide server state** (the Director's active event, #90), so the * hash only restores *which tab* of the workspace — not *which* event. The workspace always shows @@ -30,8 +30,7 @@ export type AppPage = 'home' | 'events' | 'timers' | 'pilots' | 'classes'; /** The in-event workspace sidebar tabs. */ export type WorkspaceTab = - | 'roster' - | 'classes' + | 'classes-roster' | 'rounds' | 'live' | 'marshaling' @@ -46,8 +45,7 @@ export type WorkspaceTab = export type Route = { kind: 'page'; page: AppPage } | { kind: 'workspace'; tab: WorkspaceTab }; export const WORKSPACE_TABS: readonly WorkspaceTab[] = [ - 'roster', - 'classes', + 'classes-roster', 'rounds', 'live', 'marshaling', diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 2f2f26c..981e59b 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -62,6 +62,7 @@ import { setEventClasses, setClassMembership, listFormats, + listFormatSchemas, listChannels, createRound, updateRound, @@ -88,10 +89,12 @@ import type { CreatePilotRequest, CreateTimerRequest, EventMeta, + FormatSchema, HeatId, HeatResult, HeatSummary, LiveRaceState, + MemberSlot, Pilot, PilotId, NewRoundReq, @@ -260,6 +263,7 @@ export class Session { #setEventClassesImpl: typeof setEventClasses; #setClassMembershipImpl: typeof setClassMembership; #listFormatsImpl: typeof listFormats; + #listFormatSchemasImpl: typeof listFormatSchemas; #listChannelsImpl: typeof listChannels; #createRoundImpl: typeof createRound; #updateRoundImpl: typeof updateRound; @@ -295,6 +299,7 @@ export class Session { setEventClassesImpl?: typeof setEventClasses; setClassMembershipImpl?: typeof setClassMembership; listFormatsImpl?: typeof listFormats; + listFormatSchemasImpl?: typeof listFormatSchemas; listChannelsImpl?: typeof listChannels; createRoundImpl?: typeof createRound; updateRoundImpl?: typeof updateRound; @@ -331,6 +336,7 @@ export class Session { this.#setEventClassesImpl = opts?.setEventClassesImpl ?? setEventClasses; this.#setClassMembershipImpl = opts?.setClassMembershipImpl ?? setClassMembership; this.#listFormatsImpl = opts?.listFormatsImpl ?? listFormats; + this.#listFormatSchemasImpl = opts?.listFormatSchemasImpl ?? listFormatSchemas; this.#listChannelsImpl = opts?.listChannelsImpl ?? listChannels; this.#createRoundImpl = opts?.createRoundImpl ?? createRound; this.#updateRoundImpl = opts?.updateRoundImpl ?? updateRound; @@ -637,11 +643,14 @@ export class Session { * the updated {@link EventMeta} replaces {@link currentEvent} so the workspace's view of * `classes_membership` stays in sync; returns it, `undefined` on a cancelled prompt, or throws. */ - async setClassMembership(classId: ClassId, pilotIds: PilotId[]): Promise { + async setClassMembership( + classId: ClassId, + members: (PilotId | MemberSlot)[] + ): Promise { const event = this.currentEvent; if (!event) return undefined; const updated = await this.#privilegedWrite((token) => - this.#setClassMembershipImpl(this.baseUrl, event.id, classId, pilotIds, token) + this.#setClassMembershipImpl(this.baseUrl, event.id, classId, members, token) ); if (updated) this.currentEvent = updated; return updated; @@ -661,6 +670,17 @@ export class Session { return this.#listFormatsImpl(this.baseUrl, { token: this.#token }); } + /** + * List the valid **formats + their param schemas** (`GET /formats`, open, no token) — race redesign + * Slice 7b. Each production format with the param schema its generator reads + * (`{ name, params: [{ key, label, kind, options?, default? }] }`) — the single source of truth the + * Rounds UI's guided params editor reads to offer the chosen format's params and render the right + * typed control per knob. Rejects on a transport/HTTP failure. + */ + listFormatSchemas(): Promise { + return this.#listFormatSchemasImpl(this.baseUrl, { token: this.#token }); + } + /** * List the standard **FPV channel catalog** (`GET /channels`, open, no token) — race redesign * Slice 4b. The band/channel ↔ raw-MHz vocabulary the server compiles in: the Channels UI offers diff --git a/frontend/apps/rd-console/src/screens/EventClasses.svelte b/frontend/apps/rd-console/src/screens/EventClasses.svelte deleted file mode 100644 index 731afc3..0000000 --- a/frontend/apps/rd-console/src/screens/EventClasses.svelte +++ /dev/null @@ -1,192 +0,0 @@ - - -
      - - {#snippet actions()} - - {/snippet} - - -

      - {orderedSelection.length} of {classes.length} - {classes.length === 1 ? 'class' : 'classes'} selected for this event -

      - - selected.has(c.id)} - > - {#snippet rowLead(cls)} - toggle(cls.id)} - aria-label={`Select ${cls.name}`} - /> - {/snippet} - - {#snippet listFooter()} -
      - - {orderedSelection.length} selected - -
      - {#if changed} - - {/if} - -
      -
      - {/snippet} -
      -
      -
      - - diff --git a/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte b/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte new file mode 100644 index 0000000..c5c70fa --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte @@ -0,0 +1,752 @@ + + +
      + + + {#snippet actions()} + + {/snippet} + +

      + {orderedClassSelection.length} of {classes.length} + {classes.length === 1 ? 'class' : 'classes'} selected for this event +

      + + selectedClasses.has(c.id)} + > + {#snippet rowLead(cls)} + toggleClass(cls.id)} + aria-label={`Select ${cls.name}`} + /> + {/snippet} + + {#snippet listFooter()} +
      + {orderedClassSelection.length} selected +
      + {#if classesChanged} + + {/if} + +
      +
      + {/snippet} +
      +
      + + + + {#snippet actions()} + + {/snippet} + +

      + {orderedRoster.length} of {pilots.length} + {pilots.length === 1 ? 'pilot' : 'pilots'} present at this event +

      + + selectedPilots.has(p.id)} + > + {#snippet rowLead(pilot)} + togglePilot(pilot.id)} + aria-label={`Roster ${pilot.callsign}`} + /> + {/snippet} + + {#snippet listFooter()} +
      + {orderedRoster.length} present +
      + {#if rosterChanged} + + {/if} + +
      +
      + {/snippet} +
      +
      + + + + {#snippet actions()} + {#if selectedTimers.length > 1} + + {/if} + {/snippet} + + {#if eventClasses.length === 0} +
      +

      This event has no classes selected yet.

      +

      Tick the classes it runs above first.

      +
      + {:else if rosterPilots.length === 0} +
      +

      No pilots are present yet.

      +

      Mark some pilots present above, then place them into classes.

      +
      + {:else} + {#if !hasChannelPool} +
      +

      No channels to assign yet.

      +

      + Pick a timer and give it some available channels on the + Timers tab — then each pilot can be assigned one here. +

      +
      + {:else if sourceTimer} +

      + Channels drawn from {sourceTimer.name} + {sourceTimer.id === primaryTimerId ? '(the primary timer)' : ''} — {channelOptions.length} + available. +

      + {/if} + + {#if singleClass} + +
      + + {classNameOf(singleClass)} + {rosterPilots.length} pilots + all present pilots (single class) + +
        + {#each rosterPilots as pilot (pilot.id)} +
      • + {pilot.callsign} +
        + +
        +
      • + {/each} +
      +
      + +
      +
      + {:else} + +
      + {#each eventClasses as classId (classId)} + {@const dirty = membershipChanged(classId)} +
      + + {classNameOf(classId)} + + {membersOf(classId).size} + {membersOf(classId).size === 1 ? 'pilot' : 'pilots'} + + +
        + {#each rosterPilots as pilot (pilot.id)} + {@const member = isMember(classId, pilot.id)} +
      • + + {#if member} +
        + +
        + {/if} +
      • + {/each} +
      +
      + +
      +
      + {/each} +
      + {/if} + {/if} +
      +
      + + diff --git a/frontend/apps/rd-console/src/screens/EventRoster.svelte b/frontend/apps/rd-console/src/screens/EventRoster.svelte deleted file mode 100644 index 15291b8..0000000 --- a/frontend/apps/rd-console/src/screens/EventRoster.svelte +++ /dev/null @@ -1,621 +0,0 @@ - - -
      - - - {#snippet actions()} - - {/snippet} - - -

      - {orderedSelection.length} of {pilots.length} - {pilots.length === 1 ? 'pilot' : 'pilots'} present at this event -

      - - selected.has(p.id)} - > - {#snippet rowLead(pilot)} - toggle(pilot.id)} - aria-label={`Roster ${pilot.callsign}`} - /> - {/snippet} - - {#snippet listFooter()} -
      - - {orderedSelection.length} present - -
      - {#if changed} - - {/if} - -
      -
      - {/snippet} -
      -
      - - - - {#if eventClasses.length === 0} -
      -

      This event has no classes selected yet.

      -

      Pick the classes it runs on the Classes tab first.

      -
      - {:else if rosterPilots.length === 0} -
      -

      No pilots are present yet.

      -

      Mark some pilots present above, then place them into classes.

      -
      - {:else} -
      - {#each eventClasses as classId (classId)} - {@const members = working(classId)} - {@const dirty = membershipChanged(classId)} -
      - - {classNameOf(classId)} - {members.size} {members.size === 1 ? 'pilot' : 'pilots'} - -
        - {#each rosterPilots as pilot (pilot.id)} -
      • - -
      • - {/each} -
      -
      - -
      -
      - {/each} -
      - {/if} -
      - - - - {#if rosterPilots.length === 0} -
      -

      No present pilots to bind yet.

      -
      - {:else} -
        - {#each rosterPilots as pilot (pilot.id)} - {@const ref = boundRefByPilot.get(pilot.id)} -
      • - {pilot.callsign} - {#if ref} - bound → {ref} - {:else} - unbound - {/if} -
      • - {/each} -
      - -
      -
      - - - - - - - - - -
      -
      - - {#if bindPilot !== ''} - - {bindAdapter || '—'} / {bindCompetitor || '…'} → {callsignOf(bindPilot)} - - {/if} -
      -
      - {/if} -
      -
      - - diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index b0c8197..74026fc 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -22,9 +22,12 @@ import { Button, Card, Field, Input, Select, toast } from '@gridfpv/components'; import type { ChannelCatalogEntry, + ChannelMode, Class, ClassId, CompetitorRef, + FormatParam, + FormatSchema, HeatPhase, HeatSummary, NewRoundReq, @@ -42,10 +45,13 @@ let { session }: { session: Session } = $props(); - // The app-level class directory (to resolve class ids → names) and the valid format names (the - // engine's `FormatRegistry::standard()`, via `GET /formats`). Both are open reads, loaded once. + // The app-level class directory (to resolve class ids → names) and the valid format **schemas** + // (the engine's `FormatRegistry::standard()` + each format's declared param schema, via + // `GET /formats`). Both are open reads, loaded once. The schema backs both the format dropdown and + // the guided params editor (which offers only the chosen format's params, each typed by its kind). let classes = $state([]); - let formats = $state([]); + let formatSchemas = $state([]); + const formats = $derived(formatSchemas.map((s) => s.name)); // The standard channel catalog (race redesign Slice 4b): resolves a heat's assigned raw-MHz // frequency back to a band+channel label. An open read, loaded once; an empty catalog degrades // labels to raw "5800 MHz". @@ -69,8 +75,8 @@ .then((list) => (classes = list)) .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); session - .listFormats() - .then((list) => (formats = list)) + .listFormatSchemas() + .then((list) => (formatSchemas = list)) .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); session .listPilots() @@ -322,6 +328,8 @@ type WinKind = 'Timed' | 'FirstToLaps' | 'BestLap' | 'BestConsecutive'; type SeedKind = 'FromRoster' | 'FromRanking'; + // A guided param the RD has added — the schema `key` it targets + the chosen raw `value` (the + // wire shape). The matching `FormatParam` schema (label/kind/options) is looked up by `key`. interface ParamRow { key: string; value: string; @@ -340,7 +348,15 @@ let seedKind = $state('FromRoster'); let seedSource = $state(''); let seedTopN = $state(8); + // The guided params the RD has added (a subset of the chosen format's schema), each with its + // typed value. `addParam` (a dropdown of the format's not-yet-added params) appends one seeded + // from its default; `removeParam` unsets it. Stored as `key → value` strings, the wire shape. let params = $state([]); + // The round's channel mode (Static = fixed channels / channel-balanced heats; Per-heat = assigned + // per heat, for brackets). Defaulted by format on the backend; the toggle overrides it. + let channelMode = $state('PerHeat'); + // Which param to add next (the ` + + + + +
      - {#each params as row, i (i)} + {#each params as row (row.key)} + {@const schema = paramByKey.get(row.key)}
      - - + {schema?.label ?? row.key} +
      + {#if schema?.kind === 'bool'} + + {:else if schema?.kind === 'enum'} + + {:else} + + setParamValue(row.key, (e.currentTarget as HTMLInputElement).value)} + /> + {/if} +
      {/each} - + + {#if addableParams.length > 0} +
      + + +
      + {:else if formatParams.length > 0} +

      All of this format’s params are added.

      + {/if}
      @@ -1070,7 +1197,35 @@ } .param-row { display: grid; - grid-template-columns: 1fr 1fr auto; + grid-template-columns: minmax(8rem, 1fr) 1fr auto; + gap: var(--gf-space-2); + align-items: center; + } + .param-name { + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-medium); + color: var(--gf-text); + } + .param-input { + min-width: 0; + } + .param-toggle { + display: inline-flex; + align-items: center; + gap: var(--gf-space-2); + font-size: var(--gf-font-size-md); + color: var(--gf-text); + cursor: pointer; + } + .param-toggle input { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--gf-accent); + cursor: pointer; + } + .param-add { + display: grid; + grid-template-columns: 1fr auto; gap: var(--gf-space-2); align-items: center; } diff --git a/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte index b8413c5..35fb223 100644 --- a/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte +++ b/frontend/apps/rd-console/src/screens/EventSetupWizard.svelte @@ -20,8 +20,7 @@ */ import { Button } from '@gridfpv/components'; import type { Session } from '../lib/session.svelte.js'; - import EventClasses from './EventClasses.svelte'; - import EventRoster from './EventRoster.svelte'; + import EventClassesRoster from './EventClassesRoster.svelte'; import EventTimers from './EventTimers.svelte'; import EventRounds from './EventRounds.svelte'; @@ -37,17 +36,13 @@ // The ordered steps. Each reuses an existing stage component (or, for Review, reads the live // event) — the wizard is pure orchestration, it reimplements none of the stage logic. - type StepId = 'classes' | 'roster' | 'timers' | 'rounds' | 'review'; + type StepId = 'classes-roster' | 'timers' | 'rounds' | 'review'; const STEPS: { id: StepId; label: string; blurb: string }[] = [ { - id: 'classes', - label: 'Classes', - blurb: 'Pick the classes this event runs. New classes you add here join the directory.' - }, - { - id: 'roster', - label: 'Roster', - blurb: 'Mark who is present, then place pilots into each class. Add new pilots inline.' + id: 'classes-roster', + label: 'Classes & Roster', + blurb: + 'Pick the classes this event runs, mark who is present, and place pilots into each class with their channel. Add new classes or pilots inline.' }, { id: 'timers', @@ -191,10 +186,8 @@
    - {#if current.id === 'classes'} - - {:else if current.id === 'roster'} - + {#if current.id === 'classes-roster'} + {:else if current.id === 'timers'} {:else if current.id === 'rounds'} diff --git a/frontend/apps/rd-console/tests/EventClasses.test.ts b/frontend/apps/rd-console/tests/EventClasses.test.ts deleted file mode 100644 index 1c7b45b..0000000 --- a/frontend/apps/rd-console/tests/EventClasses.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/svelte'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import type { Class, EventMeta } from '@gridfpv/types'; -import EventClasses from '../src/screens/EventClasses.svelte'; -import { makeTestSession } from './support.js'; - -const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; -const HOUSE: Class = { id: 'c2', name: 'House', source: 'Custom' }; - -/** An event that already selects Open (so its checkbox seeds checked). */ -const EVENT: EventMeta = { - id: 'e1', - name: 'Friday', - created_at: 0, - persistent: true, - timers: ['mock'], - roster: [], - classes: ['c1'] -}; - -describe('EventClasses (in-event selection + inline CRUD)', () => { - it('seeds the checkboxes from the event selection and shows the count', async () => { - const listClassesImpl = vi.fn(async () => [OPEN, HOUSE]); - const { session } = makeTestSession({ listClassesImpl, event: EVENT }); - render(EventClasses, { session }); - - const openBox = (await screen.findByLabelText('Select Open')) as HTMLInputElement; - const houseBox = screen.getByLabelText('Select House') as HTMLInputElement; - expect(openBox.checked).toBe(true); - expect(houseBox.checked).toBe(false); - - // The header count reflects 1 of 2 selected. - expect(screen.getByText(/of 2 classes selected for this event/i)).toBeInTheDocument(); - expect(within(screen.getByText(/selected for this event/i)).getByText('1')).toBeInTheDocument(); - - // No change yet → Save disabled. - expect( - (screen.getByRole('button', { name: 'Save classes' }) as HTMLButtonElement).disabled - ).toBe(true); - }); - - it('toggling a row saves the working selection via setEventClasses in directory order', async () => { - const listClassesImpl = vi.fn(async () => [OPEN, HOUSE]); - const setEventClassesImpl = vi.fn(async () => ({ ...EVENT, classes: ['c1', 'c2'] })); - const { session } = makeTestSession({ listClassesImpl, setEventClassesImpl, event: EVENT }); - render(EventClasses, { session }); - - const houseBox = (await screen.findByLabelText('Select House')) as HTMLInputElement; - await fireEvent.click(houseBox); - - const save = screen.getByRole('button', { name: 'Save classes' }) as HTMLButtonElement; - expect(save.disabled).toBe(false); - await fireEvent.click(save); - - await waitFor(() => expect(setEventClassesImpl).toHaveBeenCalledTimes(1)); - expect(setEventClassesImpl).toHaveBeenCalledWith('http://d.local', 'e1', ['c1', 'c2'], 'tok'); - await waitFor(() => expect(session.currentEvent?.classes).toEqual(['c1', 'c2'])); - }); - - it('an inline-created class becomes selectable without leaving the event', async () => { - const created: Class = { id: 'c9', name: 'Spec', source: 'MultiGP' }; - let calls = 0; - const listClassesImpl = vi.fn(async () => (calls++ === 0 ? [OPEN] : [OPEN, created])); - const createClassImpl = vi.fn(async () => created); - const { session } = makeTestSession({ - listClassesImpl, - createClassImpl, - event: { ...EVENT, classes: [] } - }); - render(EventClasses, { session }); - - await screen.findByLabelText('Select Open'); - await fireEvent.click(screen.getByRole('button', { name: '+ Add class' })); - - const name = (await screen.findByLabelText('Name')) as HTMLInputElement; - await fireEvent.input(name, { target: { value: 'Spec' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Add class' })); - - await waitFor(() => expect(createClassImpl).toHaveBeenCalledTimes(1)); - const newBox = (await screen.findByLabelText('Select Spec')) as HTMLInputElement; - expect(newBox.checked).toBe(false); - await fireEvent.click(newBox); - expect(newBox.checked).toBe(true); - }); - - it('nudges to add classes when the directory is empty', async () => { - const listClassesImpl = vi.fn(async () => []); - const { session } = makeTestSession({ listClassesImpl, event: { ...EVENT, classes: [] } }); - render(EventClasses, { session }); - await screen.findByText(/No classes in the directory yet/i); - }); -}); diff --git a/frontend/apps/rd-console/tests/EventClassesRoster.test.ts b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts new file mode 100644 index 0000000..1760099 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { ChannelCatalogEntry, Class, EventMeta, Pilot, Timer } from '@gridfpv/types'; +import EventClassesRoster from '../src/screens/EventClassesRoster.svelte'; +import { makeTestSession } from './support.js'; + +const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; + +const OPEN: Class = { id: 'open', name: 'Open Class', source: 'Custom' }; +const SPEC: Class = { id: 'spec', name: 'Spec', source: 'Custom' }; + +// A primary timer carrying two available channels — the pool the per-pilot dropdowns draw from. +const MOCK: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [5658, 5695] +} as unknown as Timer; + +const CATALOG: ChannelCatalogEntry[] = [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'Raceband', channel: 'R2', mhz: 5695 } +]; + +/** Inert directory reads + the primary-timer registry + the channel catalog. */ +function impls(extra: Record = {}) { + return { + listPilotsImpl: vi.fn(async () => [ACE, BEE]), + listClassesImpl: vi.fn(async () => [OPEN, SPEC]), + listTimersImpl: vi.fn(async () => [MOCK]), + listChannelsImpl: vi.fn(async () => CATALOG), + ...extra + }; +} + +/** A single-class event that rosters both pilots and selects its one timer. */ +const SINGLE: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + primary_timer: 'mock', + roster: ['p1', 'p2'], + classes: ['open'] +}; + +describe('EventClassesRoster — single-class auto-fill', () => { + it('auto-places every roster pilot into the lone class (no per-class checkboxes)', async () => { + const { session } = makeTestSession({ ...impls(), event: SINGLE }); + render(EventClassesRoster, { session }); + + // The placement section lists each present pilot under the single class — no "Place … in" + // checkboxes (auto-filled), just a channel dropdown per pilot. + const grid = await screen.findByRole('group', { name: 'Placement for Open Class' }); + expect(within(grid).getByText('Ace')).toBeInTheDocument(); + expect(within(grid).getByText('Bee')).toBeInTheDocument(); + expect(within(grid).getByText('all present pilots (single class)')).toBeInTheDocument(); + expect(screen.queryByLabelText('Place Ace in Open Class')).not.toBeInTheDocument(); + // A channel selector exists per pilot. + expect(screen.getByLabelText('Channel for Ace')).toBeInTheDocument(); + expect(screen.getByLabelText('Channel for Bee')).toBeInTheDocument(); + }); + + it('keeps the lone class in sync as the roster grows (live)', async () => { + const { session } = makeTestSession({ + ...impls(), + event: { ...SINGLE, roster: ['p1'] } + }); + render(EventClassesRoster, { session }); + + const grid = await screen.findByRole('group', { name: 'Placement for Open Class' }); + expect(within(grid).getByText('Ace')).toBeInTheDocument(); + expect(within(grid).queryByText('Bee')).not.toBeInTheDocument(); + + // The active-event roster grows (e.g. the sim reconciler adds a player) — auto-fill follows. + session.currentEvent = { ...SINGLE, roster: ['p1', 'p2'] }; + await waitFor(() => + expect( + within(screen.getByRole('group', { name: 'Placement for Open Class' })).getByText('Bee') + ).toBeInTheDocument() + ); + }); + + it('shows per-class placement checkboxes when ≥2 classes are selected', async () => { + const { session } = makeTestSession({ + ...impls(), + event: { ...SINGLE, classes: ['open', 'spec'] } + }); + render(EventClassesRoster, { session }); + + // Two classes → the per-class checkbox grid returns (no auto-fill). + expect(await screen.findByLabelText('Place Ace in Open Class')).toBeInTheDocument(); + expect(screen.getByLabelText('Place Bee in Spec')).toBeInTheDocument(); + expect(screen.queryByText('all present pilots (single class)')).not.toBeInTheDocument(); + }); +}); + +describe('EventClassesRoster — per-pilot channel (sourced from the primary timer)', () => { + it('populates the channel dropdown from the primary timer and saves a MemberSlot', async () => { + const setClassMembershipImpl = vi.fn(async () => ({ + ...SINGLE, + classes_membership: [{ class: 'open', pilots: [{ pilot: 'p1', channel: 5658 }] }] + })); + const { session } = makeTestSession({ + ...impls({ setClassMembershipImpl }), + event: SINGLE + }); + render(EventClassesRoster, { session }); + + // The dropdown offers the primary timer's two available channels, labelled via the catalog. + const sel = (await screen.findByLabelText('Channel for Ace')) as HTMLSelectElement; + const options = within(sel) + .getAllByRole('option') + .map((o) => o.textContent?.trim()); + expect(options).toContain('Raceband R1 · 5658 MHz'); + expect(options).toContain('Raceband R2 · 5695 MHz'); + expect(options).toContain('No channel'); + + // Assign Ace a channel and save the placement → the wire carries a MemberSlot with the channel. + await fireEvent.change(sel, { target: { value: '5658' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save placement' })); + + await waitFor(() => expect(setClassMembershipImpl).toHaveBeenCalledTimes(1)); + // The wire carries the class id + MemberSlots — both auto-filled pilots, Ace with his channel, + // Bee channel-less (a single-class event auto-fills every roster pilot into the lone class). + expect(setClassMembershipImpl).toHaveBeenCalledWith( + 'http://d.local', + 'e1', + 'open', + [{ pilot: 'p1', channel: 5658 }, { pilot: 'p2' }], + 'tok' + ); + }); + + it('nudges to configure a timer’s channels when the primary has none', async () => { + const { session } = makeTestSession({ + ...impls({ + listTimersImpl: vi.fn(async () => [{ ...MOCK, available_channels: [] }]) + }), + event: SINGLE + }); + render(EventClassesRoster, { session }); + + await screen.findByRole('group', { name: 'Placement for Open Class' }); + expect(screen.getByText(/No channels to assign yet/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/EventRoster.test.ts b/frontend/apps/rd-console/tests/EventRoster.test.ts deleted file mode 100644 index 6946547..0000000 --- a/frontend/apps/rd-console/tests/EventRoster.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/svelte'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import type { Class, EventMeta, LiveRaceState, Pilot } from '@gridfpv/types'; -import EventRoster from '../src/screens/EventRoster.svelte'; -import { makeTestSession } from './support.js'; - -const ACE: Pilot = { id: 'p1', callsign: 'Ace', name: 'Alice', vtx_types: [], attributes: {} }; -const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; - -const OPEN: Class = { id: 'open', name: 'Open Class', source: 'Custom' }; - -/** An event that already rosters Ace (so its checkbox seeds checked). */ -const EVENT: EventMeta = { - id: 'e1', - name: 'Friday', - created_at: 0, - persistent: true, - timers: ['mock'], - roster: ['p1'], - classes: [] -}; - -describe('EventRoster — present pilots (in-event roster + inline CRUD)', () => { - it('seeds the checkboxes from the event roster and shows the count', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const { session } = makeTestSession({ listPilotsImpl, event: EVENT }); - render(EventRoster, { session }); - - const aceBox = (await screen.findByLabelText('Roster Ace')) as HTMLInputElement; - const beeBox = screen.getByLabelText('Roster Bee') as HTMLInputElement; - expect(aceBox.checked).toBe(true); - expect(beeBox.checked).toBe(false); - - // The header count reflects 1 of 2 present. - expect(screen.getByText(/of 2 pilots present at this event/i)).toBeInTheDocument(); - expect(within(screen.getByText(/present at this event/i)).getByText('1')).toBeInTheDocument(); - - // No change yet → Save disabled. - expect( - (screen.getByRole('button', { name: 'Save roster' }) as HTMLButtonElement).disabled - ).toBe(true); - }); - - it('toggling a row saves the working roster via setEventRoster in directory order', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const setEventRosterImpl = vi.fn(async () => ({ ...EVENT, roster: ['p1', 'p2'] })); - const { session } = makeTestSession({ listPilotsImpl, setEventRosterImpl, event: EVENT }); - render(EventRoster, { session }); - - const beeBox = (await screen.findByLabelText('Roster Bee')) as HTMLInputElement; - await fireEvent.click(beeBox); - - const save = screen.getByRole('button', { name: 'Save roster' }) as HTMLButtonElement; - expect(save.disabled).toBe(false); - await fireEvent.click(save); - - await waitFor(() => expect(setEventRosterImpl).toHaveBeenCalledTimes(1)); - expect(setEventRosterImpl).toHaveBeenCalledWith('http://d.local', 'e1', ['p1', 'p2'], 'tok'); - // currentEvent re-homes to the server's response, so the saved roster sticks. - await waitFor(() => expect(session.currentEvent?.roster).toEqual(['p1', 'p2'])); - }); - - it('unchecking a rostered pilot saves the smaller roster', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const setEventRosterImpl = vi.fn(async () => ({ ...EVENT, roster: [] })); - const { session } = makeTestSession({ listPilotsImpl, setEventRosterImpl, event: EVENT }); - render(EventRoster, { session }); - - const aceBox = (await screen.findByLabelText('Roster Ace')) as HTMLInputElement; - await fireEvent.click(aceBox); // remove the only rostered pilot - await fireEvent.click(screen.getByRole('button', { name: 'Save roster' })); - - await waitFor(() => expect(setEventRosterImpl).toHaveBeenCalledTimes(1)); - expect(setEventRosterImpl).toHaveBeenCalledWith('http://d.local', 'e1', [], 'tok'); - }); - - it('an inline-created pilot becomes selectable without leaving the event', async () => { - const created: Pilot = { id: 'p9', callsign: 'Newbie', vtx_types: [], attributes: {} }; - let calls = 0; - const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [ACE] : [ACE, created])); - const createPilotImpl = vi.fn(async () => created); - const { session } = makeTestSession({ - listPilotsImpl, - createPilotImpl, - event: { ...EVENT, roster: [] } - }); - render(EventRoster, { session }); - - await screen.findByLabelText('Roster Ace'); - await fireEvent.click(screen.getByRole('button', { name: '+ Add pilot' })); - - const callsign = (await screen.findByLabelText('Callsign')) as HTMLInputElement; - await fireEvent.input(callsign, { target: { value: 'Newbie' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); - - await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); - // The newly-created pilot appears as a fresh, unchecked, selectable row. - const newBox = (await screen.findByLabelText('Roster Newbie')) as HTMLInputElement; - expect(newBox.checked).toBe(false); - await fireEvent.click(newBox); - expect(newBox.checked).toBe(true); - }); - - it('nudges to add pilots when the directory is empty', async () => { - const listPilotsImpl = vi.fn(async () => []); - const { session } = makeTestSession({ listPilotsImpl, event: { ...EVENT, roster: [] } }); - render(EventRoster, { session }); - await screen.findByText(/No pilots in the directory yet/i); - }); -}); - -describe('EventRoster — per-class membership', () => { - // An event that runs the Open class and has Ace + Bee present. - const EV_CLASS: EventMeta = { ...EVENT, roster: ['p1', 'p2'], classes: ['open'] }; - - it('nudges to pick classes first when the event has none selected', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const { session } = makeTestSession({ listPilotsImpl, event: EVENT }); - render(EventRoster, { session }); - await screen.findByText(/no classes selected yet/i); - }); - - it('places a roster pilot into a class and saves via setClassMembership in roster order', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const listClassesImpl = vi.fn(async () => [OPEN]); - const setClassMembershipImpl = vi.fn(async () => ({ - ...EV_CLASS, - classes_membership: [{ class: 'open', pilots: [{ pilot: 'p1' }] }] - })); - const { session } = makeTestSession({ - listPilotsImpl, - listClassesImpl, - setClassMembershipImpl, - event: EV_CLASS - }); - render(EventRoster, { session }); - - // The class section resolves the id → name and lists the roster pilots as checkboxes. - const aceInOpen = (await screen.findByLabelText('Place Ace in Open Class')) as HTMLInputElement; - expect(aceInOpen.checked).toBe(false); - await fireEvent.click(aceInOpen); - - const save = screen.getByRole('button', { name: 'Save membership' }) as HTMLButtonElement; - expect(save.disabled).toBe(false); - await fireEvent.click(save); - - await waitFor(() => expect(setClassMembershipImpl).toHaveBeenCalledTimes(1)); - expect(setClassMembershipImpl).toHaveBeenCalledWith( - 'http://d.local', - 'e1', - 'open', - ['p1'], - 'tok' - ); - // currentEvent re-homes; the membership sticks (the box stays checked, Save goes disabled). - await waitFor(() => - expect((screen.getByLabelText('Place Ace in Open Class') as HTMLInputElement).checked).toBe( - true - ) - ); - await waitFor(() => - expect( - (screen.getByRole('button', { name: 'Save membership' }) as HTMLButtonElement).disabled - ).toBe(true) - ); - }); - - it('seeds the class checkboxes from the saved membership', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const listClassesImpl = vi.fn(async () => [OPEN]); - const { session } = makeTestSession({ - listPilotsImpl, - listClassesImpl, - event: { ...EV_CLASS, classes_membership: [{ class: 'open', pilots: [{ pilot: 'p2' }] }] } - }); - render(EventRoster, { session }); - - const beeInOpen = (await screen.findByLabelText('Place Bee in Open Class')) as HTMLInputElement; - expect(beeInOpen.checked).toBe(true); - expect((screen.getByLabelText('Place Ace in Open Class') as HTMLInputElement).checked).toBe( - false - ); - }); -}); - -describe('EventRoster — binding', () => { - const EV_PRESENT: EventMeta = { ...EVENT, roster: ['p1', 'p2'], classes: [] }; - - it('shows a present pilot as unbound, then bound from the live heat registrations', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - // Ace is bound to competitor "node-1" in the current heat; Bee is not. - const live: LiveRaceState = { - phase: 'Running', - progress: [{ competitor: 'node-1', pilot: 'p1', laps_completed: 0 }] - }; - const { session } = makeTestSession({ listPilotsImpl, live, event: EV_PRESENT }); - render(EventRoster, { session }); - - const bindList = await screen.findByRole('list', { name: 'Pilot bindings' }); - expect(within(bindList).getByText(/bound → node-1/i)).toBeInTheDocument(); - expect(within(bindList).getByText(/^unbound$/i)).toBeInTheDocument(); - }); - - it('binds a competitor to a pilot via Register through the control path', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const { session, sendSpy } = makeTestSession({ listPilotsImpl, event: EV_PRESENT }); - render(EventRoster, { session }); - - const adapter = (await screen.findByLabelText('Bind adapter')) as HTMLInputElement; - const competitor = screen.getByLabelText('Bind competitor') as HTMLInputElement; - const pilot = screen.getByLabelText('Bind pilot') as HTMLSelectElement; - - await fireEvent.input(adapter, { target: { value: 'rh-1' } }); - await fireEvent.input(competitor, { target: { value: 'node-3' } }); - await fireEvent.change(pilot, { target: { value: 'p2' } }); - - const bindBtn = screen.getByRole('button', { name: 'Bind' }) as HTMLButtonElement; - expect(bindBtn.disabled).toBe(false); - await fireEvent.click(bindBtn); - - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1)); - expect(sendSpy).toHaveBeenCalledWith({ - Register: { adapter: 'rh-1', competitor: 'node-3', pilot: 'p2' } - }); - }); - - it('keeps Bind disabled until adapter, competitor, and pilot are all set', async () => { - const listPilotsImpl = vi.fn(async () => [ACE, BEE]); - const { session } = makeTestSession({ listPilotsImpl, event: EV_PRESENT }); - render(EventRoster, { session }); - - // The adapter defaults to "sim", so only competitor + pilot are missing. - const bindBtn = (await screen.findByRole('button', { name: 'Bind' })) as HTMLButtonElement; - expect(bindBtn.disabled).toBe(true); - await fireEvent.input(screen.getByLabelText('Bind competitor'), { - target: { value: 'node-3' } - }); - expect(bindBtn.disabled).toBe(true); // still no pilot - await fireEvent.change(screen.getByLabelText('Bind pilot'), { target: { value: 'p1' } }); - expect(bindBtn.disabled).toBe(false); - }); -}); diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index cc0c762..58be60f 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -43,6 +43,28 @@ const EVENT: EventMeta = { const FORMATS = ['double_elim', 'multi_main', 'round_robin', 'single_elim', 'timed_qual', 'zippyq']; +// The guided-params schemas the screen reads (`GET /formats`). `timed_qual` declares a couple of +// typed params (a number `rounds` + an enum `tiebreak`); the rest declare none, so they offer no +// params editor. Keeps the existing add/edit tests green (they don't touch params) and backs the +// guided-params test below. +const SCHEMAS = FORMATS.map((name) => + name === 'timed_qual' + ? { + name, + params: [ + { key: 'rounds', label: 'Rounds', kind: 'number' as const, default: '3' }, + { + key: 'tiebreak', + label: 'Tiebreak', + kind: 'enum' as const, + options: ['best_lap', 'count_back'], + default: 'best_lap' + } + ] + } + : { name, params: [] } +); + const CATALOG: ChannelCatalogEntry[] = [ { band: 'Raceband', channel: 'R1', mhz: 5658 }, { band: 'Fatshark', channel: 'F4', mhz: 5800 } @@ -51,7 +73,8 @@ const CATALOG: ChannelCatalogEntry[] = [ function baseImpls() { return { listClassesImpl: vi.fn(async () => [OPEN, SPEC]), - listFormatsImpl: vi.fn(async () => FORMATS) + listFormatsImpl: vi.fn(async () => FORMATS), + listFormatSchemasImpl: vi.fn(async () => SCHEMAS) }; } @@ -176,6 +199,116 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(req.label).toBe('Qualifying R2'); }); + it('offers only the chosen format’s params via "+ Add param", typed by kind, and removes them', async () => { + const impls = baseImpls(); + const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); + const { session } = makeTestSession({ + ...impls, + createRoundImpl, + event: { ...EVENT, rounds: [] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Q' } }); + await fireEvent.click(screen.getByLabelText('Eligible Open')); + // timed_qual declares the `rounds` (number) + `tiebreak` (enum) params; zippyq declares none. + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); + + // The add-param dropdown offers exactly this format's params. + const adder = (await screen.findByLabelText('Add param')) as HTMLSelectElement; + const offered = within(adder) + .getAllByRole('option') + .map((o) => o.textContent?.trim()); + expect(offered).toEqual(expect.arrayContaining(['+ Add param…', 'Rounds', 'Tiebreak'])); + + // Add the `rounds` param → a typed number input seeded from its default ("3"). + await fireEvent.change(adder, { target: { value: 'rounds' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); + const roundsInput = (await screen.findByLabelText('Rounds value')) as HTMLInputElement; + expect(roundsInput.type).toBe('number'); + expect(roundsInput.value).toBe('3'); + await fireEvent.input(roundsInput, { target: { value: '5' } }); + + // Add the `tiebreak` enum param → a toggleClass(cls.id)} - aria-label={`Select ${cls.name}`} - /> - {/snippet} - - {#snippet listFooter()} -
    - {orderedClassSelection.length} selected -
    - {#if classesChanged} - - {/if} - -
    -
    - {/snippet} - - - - - - {#snippet actions()} - - {/snippet} - - {@const rosterCollapse = collapse('roster')} - - {#snippet summary()} - - {orderedRoster.length} of {pilots.length} present - - {/snippet} -

    - {orderedRoster.length} of {pilots.length} - {pilots.length === 1 ? 'pilot' : 'pilots'} present at this event + {orderedClassSelection.length} of {classes.length} + {classes.length === 1 ? 'class' : 'classes'} selected for this event

    - selectedPilots.has(p.id)} + bind:classes + onchange={onClassDirectoryChange} + rowChecked={(c) => selectedClasses.has(c.id)} > - {#snippet rowLead(pilot)} + {#snippet rowLead(cls)} togglePilot(pilot.id)} - aria-label={`Roster ${pilot.callsign}`} + checked={selectedClasses.has(cls.id)} + onchange={() => toggleClass(cls.id)} + aria-label={`Select ${cls.name}`} /> {/snippet} {#snippet listFooter()}
    - {orderedRoster.length} present + {orderedClassSelection.length} selected
    - {#if rosterChanged} - + {#if classesChanged} + {/if}
    {/snippet} -
    -
    -
    - - - + + + + + + + {#snippet summary()} + {orderedRoster.length} of {pilots.length} present + {/snippet} {#snippet actions()} - {#if selectedTimers.length > 1} - - {/if} + {/snippet} - {#if eventClasses.length === 0} -
    -

    This event has no classes selected yet.

    -

    Tick the classes it runs above first.

    -
    - {:else if rosterPilots.length === 0} -
    -

    No pilots are present yet.

    -

    Mark some pilots present above, then place them into classes.

    -
    - {:else} - {#if !hasChannelPool} -
    -

    No channels to assign yet.

    -

    - Pick a timer and give it some available channels on the - Timers tab — then each pilot can be assigned one here. -

    + + + {#snippet actions()} +
    + +
    - {:else if sourceTimer} -

    - Channels drawn from {sourceTimer.name} - {sourceTimer.id === primaryTimerId ? '(the primary timer)' : ''} — {channelOptions.length} - available. + {/snippet} + + {@const rosterCollapse = collapse('roster')} + + {#snippet summary()} + + {orderedRoster.length} of {pilots.length} present + + {/snippet} + +

    + {orderedRoster.length} of {pilots.length} + {pilots.length === 1 ? 'pilot' : 'pilots'} present at this event

    - {/if} - {#if singleClass} + selectedPilots.has(p.id)} + > + {#snippet rowLead(pilot)} + togglePilot(pilot.id)} + aria-label={`Roster ${pilot.callsign}`} + /> + {/snippet} + + {#snippet listFooter()} +
    + {orderedRoster.length} present +
    + {#if rosterChanged} + + {/if} + +
    +
    + {/snippet} +
    + +
    + + + + {#if eventClasses.length === 0} +
    +

    This event has no classes selected yet.

    +

    Tick the classes it runs in the Classes box above first.

    +
    + {:else if rosterPilots.length === 0} +
    +

    No pilots are present yet.

    +

    Mark some pilots present above, then place them into classes.

    +
    + {:else if singleClass} {@const sc = collapse(`place:${singleClass}`)} unsaved{/if} {/snippet}
    +
    + + +
      {#each rosterPilots as pilot (pilot.id)} {@const member = isMember(classId, pilot.id)} @@ -621,8 +724,64 @@ {/each}
    {/if} - {/if} - + +
    + + + + {#snippet summary()} + {channelOptions.length} available + {/snippet} + {#snippet actions()} + {#if selectedTimers.length > 1} + + {/if} + {/snippet} + + + {#if !hasChannelPool} +
    +

    No channels to assign yet.

    +

    + Pick a timer and give it some available channels on the + Timers tab — then each pilot can be assigned one. +

    +
    + {:else if sourceTimer} +

    + Channels drawn from {sourceTimer.name} + {sourceTimer.id === primaryTimerId ? '(the primary timer)' : ''} — {channelOptions.length} + available. +

    + {/if} + +
    + + {placedTotal} + {placedTotal === 1 ? 'pilot placed' : 'pilots placed'} + + +
    +
    +
    diff --git a/frontend/apps/rd-console/tests/ConfirmButton.test.ts b/frontend/apps/rd-console/tests/ConfirmButton.test.ts new file mode 100644 index 0000000..7be3623 --- /dev/null +++ b/frontend/apps/rd-console/tests/ConfirmButton.test.ts @@ -0,0 +1,71 @@ +/** + * Unit tests for the two-step confirm button used by destructive heat off-ramps + * (Revert / Restart / Abort / Discard). Covers the confirm flow *and* the prominent + * "active confirm" styling: when armed, the Confirm button carries the `confirm-danger` + * class that paints it a solid red (danger) fill with near-black text — an unmistakable + * destructive affordance (vs. the restrained default danger style). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import { createRawSnippet } from 'svelte'; +import ConfirmButton from '../src/lib/ConfirmButton.svelte'; + +/** A bare text label snippet for the button's children (e.g. "Revert"). */ +const label = (text: string) => createRawSnippet(() => ({ render: () => `${text}` })); + +describe('ConfirmButton', () => { + it('arms on first click and fires onconfirm only on the explicit Confirm', async () => { + const onconfirm = vi.fn(); + render(ConfirmButton, { onconfirm, variant: 'danger', children: label('Revert') }); + + // First click arms — no action yet, and a Confirm + Cancel appear. + await fireEvent.click(screen.getByRole('button', { name: 'Revert' })); + expect(onconfirm).not.toHaveBeenCalled(); + const confirm = screen.getByRole('button', { name: 'Confirm' }); + expect(confirm).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + + // The explicit Confirm fires the action. + await fireEvent.click(confirm); + expect(onconfirm).toHaveBeenCalledTimes(1); + }); + + it('the armed Confirm is the prominent danger affordance (solid red fill + dark text)', async () => { + render(ConfirmButton, { + onconfirm: vi.fn(), + variant: 'danger', + children: label('Restart') + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Restart' })); + const confirm = screen.getByRole('button', { name: 'Confirm' }); + // Inverted, high-prominence styling: the danger variant + the forwarded `data-confirm-danger` + // hook the stylesheet repaints to a solid red fill with near-black letters. The hook is a + // data-attribute (not a class) so the Button primitive keeps its own `gf-btn` base class. + expect(confirm).toHaveAttribute('data-confirm-danger'); + expect(confirm).toHaveClass('gf-btn'); + expect(confirm).toHaveAttribute('data-variant', 'danger'); + }); + + it('Cancel disarms without firing', async () => { + const onconfirm = vi.fn(); + render(ConfirmButton, { onconfirm, children: label('Discard') }); + + await fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onconfirm).not.toHaveBeenCalled(); + // Back to the single resting button. + expect(screen.getByRole('button', { name: 'Discard' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Confirm' })).toBeNull(); + }); + + it('confirm={false} is a plain one-click button (no arming)', async () => { + const onconfirm = vi.fn(); + render(ConfirmButton, { onconfirm, confirm: false, children: label('Start') }); + + await fireEvent.click(screen.getByRole('button', { name: 'Start' })); + expect(onconfirm).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: 'Confirm' })).toBeNull(); + }); +}); diff --git a/frontend/e2e/tone-and-confirm.spec.ts b/frontend/e2e/tone-and-confirm.spec.ts new file mode 100644 index 0000000..67fee90 --- /dev/null +++ b/frontend/e2e/tone-and-confirm.spec.ts @@ -0,0 +1,121 @@ +/** + * Live-control e2e proof for two fixes: + * + * A. **Audible start tone** — the race-go beep was inaudible because the `AudioContext` stays + * *suspended* (browser autoplay policy) when the runtime auto-advances `Armed → Running` with no + * click on that edge. The console now **unlocks the context on an earlier RD gesture** (any heat + * transition / the mute toggle). Real audio can't be asserted headless, so we inject a page-side + * `AudioContext` **stub** that records `resume()` + oscillator `start()` calls and assert the + * unlock fires on a control click — and that an oscillator actually starts at race-go. + * + * B. **Prominent red Revert/Restart confirm** — the active confirm is now a solid red fill with + * near-black text. We arm a destructive confirm and screenshot it. + * + * Screenshots go to `GRIDFPV_SHOTS` when set (the harness passes it). + */ +import { expect, test } from './observability.js'; + +declare global { + interface Window { + __toneCalls: { resume: number; started: number; state: string }; + } +} + +const shot = async (locator: import('@playwright/test').Locator, name: string) => { + if (process.env.GRIDFPV_SHOTS) { + await locator.screenshot({ path: `${process.env.GRIDFPV_SHOTS}/${name}.png` }); + } +}; + +// A tiny page-side AudioContext stub recording resume()/oscillator-start onto window so the spec can +// read them back. Installed via addInitScript so it's present before the app's StartTonePlayer reads +// the platform constructor. +const AUDIO_STUB = ` + window.__toneCalls = { resume: 0, started: 0, state: 'suspended' }; + class StubAudioContext { + constructor() { this.currentTime = 0; this.state = window.__toneCalls.state; this.destination = {}; } + createOscillator() { + const calls = window.__toneCalls; + return { + type: 'square', + frequency: { setValueAtTime() {} }, + connect() {}, start() { calls.started++; }, stop() {} + }; + } + createGain() { return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, connect() {} }; } + async resume() { window.__toneCalls.resume++; this.state = 'running'; } + async close() {} + } + window.AudioContext = StubAudioContext; + window.webkitAudioContext = StubAudioContext; +`; + +async function enterPractice(page: import('@playwright/test').Page) { + const liveNav = page.getByRole('button', { name: /Live control/ }); + const eventsCard = page.getByRole('button', { name: /Events/ }); + await expect(liveNav.or(eventsCard).first()).toBeVisible({ timeout: 15_000 }); + if (!(await liveNav.isVisible().catch(() => false))) { + await eventsCard.click(); + const picker = page.getByRole('heading', { name: 'Choose an event' }); + await expect(picker.or(liveNav).first()).toBeVisible({ timeout: 15_000 }); + if (await picker.isVisible().catch(() => false)) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } + await expect(liveNav).toBeVisible({ timeout: 15_000 }); + } +} + +test('start tone unlocks the AudioContext on a control click and sounds at race-go; prominent red confirm', async ({ + page, + director +}) => { + await page.addInitScript(AUDIO_STUB); + await page.goto('/'); + await enterPractice(page); + await expect(page.locator('.conn-label')).toHaveText('live', { timeout: 15_000 }); + + // Schedule a heat over the open control path. + const ack = await page.request.post(`${director.baseUrl}/events/practice/control`, { + headers: { 'Content-Type': 'application/json' }, + data: { ScheduleHeat: { heat: 'tone-1', lineup: ['Ace', 'Bee', 'Cee'] } } + }); + expect(ack.ok()).toBeTruthy(); + await expect(page.locator('.heat-id .value')).toHaveText('tone-1', { timeout: 15_000 }); + + // Before any gesture the context has not been resumed. + expect(await page.evaluate(() => window.__toneCalls.resume)).toBe(0); + + // ── A: the unlock fires on a control click (Stage is a non-destructive gesture) ─────────────── + await page.getByRole('button', { name: 'Stage', exact: true }).click(); + await expect(page.getByRole('status', { name: 'Staging countdown' })).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__toneCalls.resume)).toBeGreaterThan(0); + + // Start arms the heat; the runtime then auto-advances Armed → Running (no click on that edge). + await page.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Running', { timeout: 15_000 }); + // An oscillator actually started at race-go (the context was unlocked by the earlier gesture). + await expect.poll(() => page.evaluate(() => window.__toneCalls.started)).toBeGreaterThan(0); + + // Drive to a state where a destructive confirm (Restart) is available, then arm it. + await page.getByRole('button', { name: 'ForceEnd', exact: true }).click(); + await expect(page.locator('.phase').first()).toHaveText('Unofficial', { timeout: 15_000 }); + + // ── B: the prominent red confirm — arm Restart and screenshot the solid-red Confirm button ──── + await page.getByRole('button', { name: 'Restart', exact: true }).click(); + const confirm = page.getByRole('button', { name: 'Confirm' }); + await expect(confirm).toBeVisible(); + await expect(confirm).toHaveAttribute('data-confirm-danger', 'true'); + // Prove the prominent fill actually computed to the solid danger red (not the subtle default). + await expect + .poll(() => confirm.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe('rgb(255, 107, 107)'); + await shot(page.locator('.controls'), 'revert-restart-confirm'); + + // Cancel the confirm, then clean up the heat (Discard) so the shared Director is left tidy. + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Discard', exact: true }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); +}); From 4a68a601b029be2a9217299e0ac0c63fd3dd2047 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 18:43:40 +0000 Subject: [PATCH 134/362] Open practice Slice 1: open_practice format + AllChannels round + in-memory per-channel laps + live delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the casual open-practice mode (backend): - engine: an `open_practice` format (OpenPractice generator) that emits exactly one open heat over the active channels (the `node-{i}` field) then Complete; registered in FormatRegistry::standard + standard_schemas (no params). - server/events: a new `SeedingRule::AllChannels { channels: Vec }` variant (additive, ts-rs). An open-practice round is `format: "open_practice"` + `seeding: AllChannels{..}`; classes may be empty. - server/round_engine: round_field lays AllChannels out as `node-{i}` refs; fill_round emits the heat with EMPTY frequencies; `is_open_practice` helper. - in-memory accumulator (NOT logged): a per-event OpenPracticeLive cell on AppState holds the heat's per-channel passes in memory. The source bridge routes an open-practice heat's passes there (via PassSink::for_open_practice) instead of state.append, so the log carries the heat's HeatScheduled + start/stop and ZERO Pass events (the session is logged, the laps are not). - live delivery: the accumulator computes a LiveRaceState by feeding a synthetic HeatScheduled + the accumulated passes to the existing live_state fold (per channel, pilot: None). The /stream + snapshot live-state fold overlay it while active; each accumulator mutation calls AppState::wake_streams so a parked stream re-folds and pushes a fresh-value LiveRaceState envelope. This reuses the whole existing snapshot/change-stream machinery — no new channel, no new wire type — at the cost of one per-event overlay cell the fold consults. - clear-on-stop: the accumulator is dropped (and an idle live state pushed) when the open-practice heat reaches a terminal/abort/restart transition or a new heat/round becomes active; the Finished→Unofficial step keeps the laps so the RD sees the final practice laps before finalizing. Tests: the open_practice format emits one heat then Complete; FillRound logs HeatScheduled with the node lineup + empty frequencies; an end-to-end bridge test feeds an open-practice heat sim passes and asserts the log has zero Pass events (but the HeatScheduled + start/stop), the live state shows per-channel laps with pilot: None, and the accumulator clears on stop. Frontend: keep it compiling (SeedingRule binding regenerated; EventRounds narrows the new variant; a `/formats` + AllChannels contract case added). The active-channels picker + the live per-channel UI are Slice 2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/SeedingRule.ts | 10 +- crates/app/src/source.rs | 242 +++++++++++++++- crates/engine/src/format.rs | 119 +++++++- crates/server/src/app.rs | 51 +++- crates/server/src/events.rs | 16 +- crates/server/src/lib.rs | 1 + crates/server/src/open_practice.rs | 258 ++++++++++++++++++ crates/server/src/round_engine.rs | 111 +++++++- crates/server/src/ws.rs | 62 ++++- .../rd-console/src/screens/EventRounds.svelte | 10 +- frontend/contract/events.contract.ts | 37 ++- 11 files changed, 892 insertions(+), 25 deletions(-) create mode 100644 crates/server/src/open_practice.rs diff --git a/bindings/SeedingRule.ts b/bindings/SeedingRule.ts index 72719dd..a87f61e 100644 --- a/bindings/SeedingRule.ts +++ b/bindings/SeedingRule.ts @@ -7,7 +7,8 @@ import type { RoundId } from "./RoundId"; * 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). Derives serde + `ts_rs::TS`. + * 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": { /** @@ -17,4 +18,9 @@ source_round: RoundId, /** * How many of the source ranking's top places advance into this round. */ -top_n: number, } }; +top_n: number, } } | { "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/crates/app/src/source.rs b/crates/app/src/source.rs index 0518abd..80a100e 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -149,6 +149,11 @@ pub struct PassSink { 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, } impl PassSink { @@ -160,6 +165,7 @@ impl PassSink { adapter, gate: None, timer: None, + open_practice: None, } } @@ -176,9 +182,19 @@ impl PassSink { adapter, gate: Some(gate), timer: Some(timer), + open_practice: 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 + } + /// 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 { @@ -202,16 +218,24 @@ impl PassSink { if !self.feeds() { return Ok(()); } - let pass = Event::Pass(Pass { + let pass = Pass { adapter: self.adapter.clone(), competitor: competitor.clone(), at, sequence: Some(sequence), gate: GateIndex::LAP, signal: None, - }); + }; + // 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(pass, None) + .append(Event::Pass(pass), None) .map_err(|e| SourceError(format!("{e:?}")))?; Ok(()) } @@ -228,6 +252,16 @@ impl PassSink { 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(()); + } + } self.state .append(event, None) .map_err(|e| SourceError(format!("{e:?}")))?; @@ -827,7 +861,8 @@ fn handle_transition( 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. + // 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")] @@ -843,6 +878,17 @@ fn handle_transition( 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 @@ -857,7 +903,11 @@ fn handle_transition( let sources = selected_sources(registry, timers, event_id); let mut handles = Vec::with_capacity(sources.len()); for (timer_id, source) in sources { - let sink = PassSink::gated(state.clone(), adapter.clone(), gate.clone(), timer_id); + let mut sink = + PassSink::gated(state.clone(), adapter.clone(), gate.clone(), timer_id); + if open_practice { + sink = sink.for_open_practice(heat.clone()); + } let run = HeatRun { heat: heat.clone(), lineup: lineup.clone(), @@ -877,12 +927,15 @@ fn handle_transition( let armed_rh = { let mut armed = Vec::new(); for timer_id in selected_rh_timers(registry, timers, event_id) { - let sink = PassSink::gated( + let mut sink = PassSink::gated( state.clone(), adapter.clone(), gate.clone(), timer_id.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); } @@ -933,6 +986,17 @@ fn handle_transition( } } } + // 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 and push the now-idle live state. 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`). + if !matches!(transition, HeatTransition::Finished) + && state.open_practice().is_active(&heat) + && state.open_practice().clear() + { + state.wake_streams(); + } } // 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, @@ -1183,6 +1247,27 @@ fn default_sim_win_condition() -> gridfpv_engine::scoring::WinCondition { } } +/// 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()?; @@ -1390,6 +1475,7 @@ 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::{ @@ -1922,6 +2008,150 @@ mod tests { 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 { + 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(), + win_condition: gridfpv_engine::scoring::WinCondition::BestLap, + seeding: SeedingRule::AllChannels { channels }, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: 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![], + }, + 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(); + } + #[test] fn source_config_defaults_to_sim_and_describes_itself() { // No env reliance: build a sim config directly and confirm the banner text. diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index 4f24faf..df3f6ec 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -468,8 +468,9 @@ impl FormatRegistry { /// [`timed_qual`](crate::timed_qual::TimedQualifying), [`zippyq`](crate::zippyq::ZippyQ), /// [`single_elim`](crate::single_elim::SingleElim), /// [`double_elim`](crate::double_elim::DoubleElim), - /// [`round_robin`](crate::round_robin::RoundRobin), and - /// [`multi_main`](crate::multi_main::MultiMain). + /// [`round_robin`](crate::round_robin::RoundRobin), + /// [`multi_main`](crate::multi_main::MultiMain), 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) @@ -483,6 +484,7 @@ impl FormatRegistry { crate::double_elim::DoubleElim::register(&mut registry); crate::round_robin::RoundRobin::register(&mut registry); crate::multi_main::MultiMain::register(&mut registry); + OpenPractice::register(&mut registry); registry } @@ -503,6 +505,8 @@ impl FormatRegistry { /// - `single_elim`: `heat_size` (number, 2). /// - `double_elim`: `bracket_reset` (bool, on). /// - `multi_main`: `main_size` (number, 4). + /// - `open_practice`: no params (the active channels are the field, carried by the round's + /// `AllChannels` seeding). /// - `zippyq`: `rounds` (number, 0 — rounds are added on demand). pub fn standard_schemas() -> Vec { vec![ @@ -514,6 +518,14 @@ impl FormatRegistry { name: "multi_main".into(), params: vec![FormatParam::number("main_size", "Main size", "4")], }, + // 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 `multi_main`, before + // `round_robin`) to match [`names`](Self::names). + FormatSchema { + name: OpenPractice::NAME.into(), + params: Vec::new(), + }, FormatSchema { name: "round_robin".into(), params: vec![ @@ -591,6 +603,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 @@ -1095,6 +1172,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] @@ -1126,12 +1239,14 @@ mod tests { vec![ "double_elim", "multi_main", + "open_practice", "round_robin", "single_elim", "timed_qual", "zippyq", ] ); + assert!(registry.contains("open_practice")); // The validation surface the server uses. assert!(registry.contains("timed_qual")); assert!(!registry.contains("knockout-demo")); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 4d4d5a1..ef41c5c 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -185,6 +185,13 @@ pub struct AppState { /// /// [`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, } impl AppState { @@ -196,6 +203,7 @@ impl AppState { log: Arc::new(Mutex::new(log)), appended: Arc::new(Notify::new()), tokens: TokenStore::new(), + open_practice: crate::open_practice::OpenPracticeLive::new(), } } @@ -206,6 +214,7 @@ impl AppState { log, appended: Arc::new(Notify::new()), tokens: TokenStore::new(), + open_practice: crate::open_practice::OpenPracticeLive::new(), } } @@ -219,6 +228,7 @@ impl AppState { log: Arc::new(Mutex::new(log)), appended: Arc::new(Notify::new()), tokens, + open_practice: crate::open_practice::OpenPracticeLive::new(), } } @@ -264,6 +274,26 @@ impl AppState { 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. @@ -1274,9 +1304,17 @@ async fn snapshot_event( ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; + // Open-practice overlay (open-practice format, Slice 1): while an open-practice heat is active + // its per-channel laps are in memory (NOT logged), so the snapshot serves the accumulator's live + // state in place of the log fold — "snapshot first, then subscribe" stays correct (the `/stream` + // fold applies the same overlay), so a client renders the live per-channel laps immediately. + let body = state + .open_practice() + .live_state() + .unwrap_or_else(|| live_state(&events)); Ok(Json(Snapshot { cursor, - body: ProjectionBody::LiveRaceState(live_state(&events)), + body: ProjectionBody::LiveRaceState(body), })) } @@ -1371,9 +1409,14 @@ async fn snapshot_heat( let body = match query.projection { HeatProjection::Live => { - // Fold the heat's window into live state (it is the only heat present, so it - // is the current one). - ProjectionBody::LiveRaceState(live_state(&heat_events)) + // Open-practice overlay (open-practice format, Slice 1): when this *is* the active + // open-practice heat, its laps live in the in-memory accumulator (NOT the log), so serve + // the accumulator's live state; otherwise fold the heat's log window as usual. + let open = state + .open_practice() + .live_state() + .filter(|s| s.current_heat.as_ref() == Some(&heat)); + ProjectionBody::LiveRaceState(open.unwrap_or_else(|| live_state(&heat_events))) } HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( heat_events.iter().enumerate().map(|(i, e)| (i as u64, e)), diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 794f9b5..d8b1758 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -512,7 +512,8 @@ impl ChannelMode { /// 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). Derives serde + `ts_rs::TS`. +/// 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, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub enum SeedingRule { @@ -530,6 +531,19 @@ pub enum SeedingRule { /// How many of the source ranking's top places advance into this round. top_n: usize, }, + /// 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, + }, } /// The body of `POST /events/{id}/rounds` — everything a caller supplies to add a round (race diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index c55ebc9..9d49114 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -65,6 +65,7 @@ pub mod control_handler; pub mod error; pub mod events; pub mod live_state; +pub mod open_practice; pub mod pilots; pub mod round_engine; pub mod scope; diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs new file mode 100644 index 0000000..36e65db --- /dev/null +++ b/crates/server/src/open_practice.rs @@ -0,0 +1,258 @@ +//! 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 — a fresh-value re-snapshot on the existing `/stream` +//! +//! The change stream folds the [`LiveRaceState`] purely from the log; non-logged laps cannot drive +//! it through the fold. So the accumulator is a small **overlay** the live-state fold checks: while +//! an open-practice heat is active, the fold returns the accumulator's computed [`LiveRaceState`] +//! (per channel, `pilot: None`) **instead of** the log fold — and every accumulator mutation wakes +//! the same append-notify ([`AppState::appended`](crate::app::AppState)) that an `append` would, so a +//! parked stream re-folds and pushes a fresh-value envelope. This reuses the whole existing +//! snapshot and change-stream machinery (the [`LiveRaceState`]/[`PilotProgress`] shape, the +//! fresh-value envelope, the WS transport) with **no new channel and no new wire type** — the one +//! cost is this per-event overlay cell the live-state fold consults. +//! +//! # 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`. + +use std::sync::{Arc, RwLock}; + +use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition, Pass}; + +use crate::live_state::{LiveRaceState, 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 live-state fold consumes: the heat's `HeatScheduled` over the + /// active channels, a `Running` transition (so the live phase reads `Running`), 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. + 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(), + }); + events.push(Event::HeatStateChanged { + heat: self.heat.clone(), + transition: HeatTransition::Running, + }); + events.extend(self.passes.iter().cloned().map(Event::Pass)); + events + } + + /// The live race-state for this open-practice heat: per-channel laps from the accumulated + /// passes, each channel an unbound competitor (`pilot: None`). + 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 reads +/// its computed live 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 transition) or a new heat/round takes + /// over. Returns whether anything was actually cleared (so the caller wakes `/stream` to push the + /// now-idle live 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 current open-practice [`LiveRaceState`] overlay, or `None` when no open-practice heat is + /// active (the `/stream` fold then uses the normal log fold). Per channel, `pilot: None`. + 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 gridfpv_events::{AdapterId, GateIndex, 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, + } + } + + #[test] + fn idle_accumulator_has_no_live_state() { + let live = OpenPracticeLive::new(); + assert!(live.live_state().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 per_channel_laps_are_derived_with_no_pilot_bound() { + let live = OpenPracticeLive::new(); + let heat = HeatId("open-practice".into()); + live.begin(heat.clone(), vec![chan(0), chan(2)]); + assert!(live.is_active(&heat)); + + // 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))); + + let state = live + .live_state() + .expect("an active open-practice live state"); + assert_eq!(state.current_heat, Some(heat)); + // Two channels, each a row; neither bound to a pilot (open practice is per channel). + assert_eq!(state.progress.len(), 2); + assert!(state.progress.iter().all(|p| p.pilot.is_none())); + + let n0 = state + .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 = state + .progress + .iter() + .find(|p| p.competitor == chan(2)) + .unwrap(); + assert_eq!(n2.laps_completed, 1); + } + + #[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)); + } + + #[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()))); + let state = live.live_state().unwrap(); + assert_eq!(state.active_pilots, vec![chan(1)]); + } +} diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 689cd16..4c08c73 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -334,9 +334,28 @@ fn round_field( let ranking = round_ranking(meta, source, events)?; Ok(advance_top_n(&ranking, *top_n)) } + // 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()), } } +/// 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 { .. }) +} + /// 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. @@ -720,12 +739,18 @@ fn fill_round_per_heat( // FillRound before the prior heat is scored does not double-schedule it. let already: Vec = scheduled_round_heats(events, round_id); let next = plans.into_iter().find(|p| !already.contains(&p.heat)); + // 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 = is_open_practice(round).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). - frequencies: None, + // 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, }), // Every plan the generator wants this step is already scheduled (the RD // re-issued FillRound before scoring the outstanding heat): nothing new to @@ -1273,6 +1298,88 @@ mod tests { )); } + /// 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(), + } + } + + #[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, + } => { + assert_eq!(heat.0, "open-practice"); + 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( + "open-practice", + "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("open-practice", vec![])); + let next = fill_round(&meta, &no_timers(), &RoundId("op1".into()), &log).unwrap(); + assert_eq!(next, FillOutcome::Complete); + } + + #[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. diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs index 1152e39..8d3b563 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -164,12 +164,32 @@ impl ScopeProjection { /// and comparing tells the engine whether offset `n` *changed* the projection (and thus /// whether to emit an envelope). Reuses the same fold helpers as the snapshot path so a /// subscriber and a snapshot of the same scope converge to the same value. - fn fold(scope: &Scope, events: &[Event]) -> Option { + /// + /// `overlay` is the event's **open-practice live state** (open-practice format, Slice 1) when an + /// open-practice heat is active, else `None`. An open-practice heat's laps are accumulated in + /// memory (NOT logged), so the log fold can't see them; while the overlay is present, the + /// live-state scopes return it instead of the log fold — for a Heat scope only when it is *that* + /// heat. The lap-list (pilot) scope is unaffected (open practice is per channel, not per pilot). + fn fold( + scope: &Scope, + events: &[Event], + overlay: Option<&crate::live_state::LiveRaceState>, + ) -> Option { match scope { Scope::Event { .. } | Scope::Class { .. } => { - Some(ProjectionBody::LiveRaceState(live_state(events))) + // While an open-practice heat is active, the (non-logged) per-channel live state + // replaces the log fold for the whole-event scopes. + let live = overlay.cloned().unwrap_or_else(|| live_state(events)); + Some(ProjectionBody::LiveRaceState(live)) } Scope::Heat { heat } => { + // An open-practice heat's live state lives in the overlay, not the log — so serve it + // when this Heat scope addresses the active open-practice heat. + if let Some(open) = overlay { + if open.current_heat.as_ref() == Some(heat) { + return Some(ProjectionBody::LiveRaceState(open.clone())); + } + } // 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 @@ -309,7 +329,12 @@ async fn run_stream(mut socket: WebSocket, state: AppState) { return; } }; - for message in engine.advance(&events) { + // The event's open-practice overlay (open-practice format, Slice 1): the per-channel, + // in-memory (NOT logged) live state when an open-practice heat is active, else `None`. The + // fold serves it in place of the log fold for the live-state scopes so the non-logged laps + // drive the stream. `wake_streams` after a pass / clear is what re-enters this loop. + let overlay = state.open_practice().live_state(); + for message in engine.advance(&events, overlay.as_ref()) { if send_message(&mut socket, &message).await.is_err() { return; // client gone } @@ -367,7 +392,17 @@ impl Engine { /// `0..=offset`; when the folded body differs from the last one emitted it produces one /// envelope (fresh value, the next sequence). Walking offset by offset keeps the /// per-stream sequence a faithful "one bump per projection change" and the order total. - fn advance(&mut self, events: &[Event]) -> Vec { + /// + /// `overlay` is the event's open-practice live state (open-practice format, Slice 1) when an + /// open-practice heat is active. The per-offset walk folds the **pure log** (overlay-free) so + /// logged changes stay gap-free; then, when the overlay is active, a final overlay-applied 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`. + fn advance( + &mut self, + events: &[Event], + overlay: Option<&crate::live_state::LiveRaceState>, + ) -> Vec { let mut out = Vec::new(); let len = events.len() as u64; @@ -377,13 +412,15 @@ impl Engine { // (`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); + 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]; - let body = ScopeProjection::fold(&self.scope, prefix); + // 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 self.last_emitted.as_ref() != Some(&body) { out.push(StreamMessage::Change(self.envelope(body.clone()))); @@ -391,6 +428,19 @@ impl Engine { } } } + + // The open-practice live re-snapshot (open-practice format, Slice 1): with an active + // overlay, fold the current prefix *with* it and emit when it differs from the last value — + // the non-logged per-channel laps reach the stream as a fresh-value `LiveRaceState`. When the + // overlay clears, the next pure-log fold (above, on the next wake) restores the logged value. + if overlay.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 } diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index c15846f..90bc63a 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -507,10 +507,14 @@ const seed = round.seeding; if (typeof seed === 'string') { seedKind = 'FromRoster'; - } else { + } else if ('FromRanking' in seed) { seedKind = 'FromRanking'; seedSource = seed.FromRanking.source_round; seedTopN = seed.FromRanking.top_n; + } else { + // AllChannels (open-practice format) — its active-channels picker is Slice 2; the Rounds + // editor here just falls back to the roster-seeded view so it stays type-safe meanwhile. + seedKind = 'FromRoster'; } // Heat-lifecycle config (Slice 3): staging timer (split mm:ss), the randomized start window, and @@ -662,6 +666,10 @@ function seedSummary(seed: SeedingRule): string { if (typeof seed === 'string') return 'From roster'; + if ('AllChannels' in seed) { + // Open practice (open-practice format): seeded from the active channels (node indices). + return `Open practice · ${seed.AllChannels.channels.length} channel(s)`; + } const { source_round, top_n } = seed.FromRanking; return `Top ${top_n} from ${roundLabel(source_round)}`; } diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 2d67e78..a75e327 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -1256,6 +1256,37 @@ describe('race Slice 2a: rounds', () => { expect((gone.body as { code?: string }).code).toBe('UnknownScope'); }); + it('POST /rounds accepts an open_practice round seeded AllChannels (open-practice format)', async () => { + // Open practice (open-practice format, Slice 1): a round is `format: "open_practice"` + + // `seeding: AllChannels { channels }` (node indices), with no eligible classes — it is keyed on + // active *channels*, not pilots. The round round-trips through the meta with its seeding intact. + const event = (await createEvent('Open Practice Round', TOKEN)).body as EventMeta; + // No class selection needed — an open-practice round has an empty classes list. + const created = await addRound( + event.id, + { + label: 'Open Practice', + classes: [], + format: 'open_practice', + params: {}, + win_condition: 'BestLap', + seeding: { AllChannels: { channels: [0, 1, 2] } } + }, + TOKEN + ); + expect(created.status).toBe(200); + const round = created.body as RoundDef; + expect(round.format).toBe('open_practice'); + expect(round.classes).toEqual([]); + expect(round.seeding).toEqual({ AllChannels: { channels: [0, 1, 2] } }); + + // It round-trips through the event meta (the seeding + format survive the persist). + const list = (await fetch(`${director.baseUrl}/events`).then((r) => r.json())) as EventMeta[]; + const meta = list.find((e) => e.id === event.id)!; + const stored = (meta.rounds ?? []).find((r) => r.id === round.id)!; + expect(stored.seeding).toEqual({ AllChannels: { channels: [0, 1, 2] } }); + }); + it('POST /rounds validates format, class selection, and seeding source → 400', async () => { const event = (await createEvent('Rounds Validation', TOKEN)).body as EventMeta; await selectOpen(event.id); @@ -1309,15 +1340,19 @@ describe('race Slice 2a: rounds', () => { const res = await fetch(`${director.baseUrl}/formats`); expect(res.status).toBe(200); const schemas = (await res.json()) as FormatSchema[]; - // The 6 production formats, in sorted name order (the same set `POST /rounds` validates against). + // The production formats, in sorted name order (the same set `POST /rounds` validates against), + // including the casual `open_practice` (open-practice format) with no param knobs. expect(schemas.map((s) => s.name)).toEqual([ 'double_elim', 'multi_main', + 'open_practice', 'round_robin', 'single_elim', 'timed_qual', 'zippyq' ]); + // open_practice declares no params (its active channels are the field, via AllChannels seeding). + expect(schemas.find((s) => s.name === 'open_practice')!.params).toEqual([]); // timed_qual declares `rounds` (number, default 3) and an enum `metric` with its options. const tq = schemas.find((s) => s.name === 'timed_qual')!; const rounds = tq.params.find((p) => p.key === 'rounds')!; From 8762592b4af9b44fc530ec95ce925ff7cadd3d8e Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 19:06:37 +0000 Subject: [PATCH 135/362] Open practice Slice 2: active-channels picker + per-channel live board + reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI half of the open-practice format. EventRounds swaps the class/seeding inputs for an active-channels picker when format=open_practice: the primary timer's node seats (node_count), each labelled band + channel · MHz via the catalog (fallback "Node i"), saved as classes:[] + seeding AllChannels{channels} (selected node indices). Reflects an edited round's selection; nudges to set a timer when none has channels. LiveRaceControl renders an open-practice heat (rows pilot:null, competitor node-{i}) as a per-channel practice board: each node-{i} → the timer's available_channels[i] → channel label, with laps + last lap + best lap (formatMicros). Best lap is tracked client-side (min last-lap over the run) since the live stream carries only last lap; it resets on a heat change. A "New run · clear board" control re-fills the round to mint a fresh heat (the backend clears its in-memory laps), wiping the board between practice sessions. Adds nodeIndexOf + nodeChannelLabel helpers and a session.primaryTimer getter. Unit tests cover the helpers, the picker → AllChannels payload + edit reflection + no-timer nudge, and the per-channel board (node→channel labels, best-lap tracking, reset). A browser e2e (e2e/open-practice.spec.ts) drives the full click-through in headless Chromium with screenshots of the picker + board. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/lib/channels.ts | 33 ++ .../apps/rd-console/src/lib/session.svelte.ts | 12 + .../rd-console/src/screens/EventRounds.svelte | 290 +++++++++++++---- .../src/screens/LiveRaceControl.svelte | 305 ++++++++++++++++-- .../apps/rd-console/tests/EventRounds.test.ts | 127 +++++++- .../rd-console/tests/LiveRaceControl.test.ts | 150 ++++++++- .../apps/rd-console/tests/channels.test.ts | 32 ++ frontend/e2e/open-practice.spec.ts | 114 +++++++ .../e2e/screenshots/open-practice-board.png | Bin 0 -> 148161 bytes .../e2e/screenshots/open-practice-picker.png | Bin 0 -> 162667 bytes 10 files changed, 960 insertions(+), 103 deletions(-) create mode 100644 frontend/e2e/open-practice.spec.ts create mode 100644 frontend/e2e/screenshots/open-practice-board.png create mode 100644 frontend/e2e/screenshots/open-practice-picker.png diff --git a/frontend/apps/rd-console/src/lib/channels.ts b/frontend/apps/rd-console/src/lib/channels.ts index 5aa442d..6d39369 100644 --- a/frontend/apps/rd-console/src/lib/channels.ts +++ b/frontend/apps/rd-console/src/lib/channels.ts @@ -90,6 +90,39 @@ export function isPlausibleMhz(mhz: number): boolean { return Number.isInteger(mhz) && mhz >= 5300 && mhz <= 6000; } +/** + * Parse the node index out of an open-practice competitor ref (`node-{i}` → `i`), or `undefined` + * for any other ref. Open-practice heats lay their channels out as `node-{i}` refs (the timer-seat + * index), so this is the join key back onto the timer's `available_channels`. + */ +export function nodeIndexOf(ref: string): number | undefined { + const m = /^node-(\d+)$/.exec(ref); + if (!m) return undefined; + return Number(m[1]); +} + +/** + * The display label for one open-practice node seat (`node-{i}`), resolved through the timer's + * `available_channels` + the catalog: + * + * - a node whose seat has a configured channel → `"Raceband R1 · 5658"` (band + channel · MHz), or + * `"5800 MHz"` when the raw MHz isn't a catalog channel; + * - a node with no configured channel (index ≥ the available pool) → `"Node {i}"`. + * + * `availableChannels` is the timer's `available_channels` (raw MHz, in seat/node order). Shared by + * the active-channels picker and the per-channel live board so both label a seat identically. + */ +export function nodeChannelLabel( + node: number, + availableChannels: number[], + catalog: ChannelCatalogEntry[] +): string { + const mhz = availableChannels[node]; + if (mhz === undefined) return `Node ${node + 1}`; + const hit = catalogEntryFor(mhz, catalog); + return hit ? `${hit.band} ${hit.channel} · ${mhz}` : `${mhz} MHz`; +} + /** * Auto-assign channels to an ordered list of pilots, deterministic round-robin (first-fit) across an * ordered `pool` of available channels: the `i`-th pilot gets `pool[i % pool.length]`. When there are diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 052ae8f..3f2763d 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -1054,6 +1054,18 @@ export class Session { return ids[0]; } + /** + * The current event's effective **primary timer** ({@link Timer}), resolving + * {@link primaryTimerId} against the polled {@link timers} registry. `undefined` when no event / + * selection, or until the first timer poll lands. Open practice reads its `node_count` + + * `available_channels` off this to lay out the active-channels picker / per-channel live board. + */ + get primaryTimer(): Timer | undefined { + const id = this.primaryTimerId; + if (!id) return undefined; + return this.timers.find((t) => t.id === id); + } + /** Re-scope the live read client within the current event (e.g. to a heat scope). */ resubscribe(scope: Scope): void { const event = this.currentEvent; diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 90bc63a..8cf7cd3 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -39,9 +39,10 @@ RoundId, SeedingRule, StartProcedure, + Timer, WinCondition } from '@gridfpv/types'; - import { channelLabel } from '../lib/channels.js'; + import { channelLabel, nodeChannelLabel } from '../lib/channels.js'; import { collapseStore } from '../lib/collapse.svelte.js'; import { advanceRoundLabel, advanceRoundReq, bracketTopNDefault } from '../lib/standings.js'; import type { Session } from '../lib/session.svelte.js'; @@ -82,6 +83,42 @@ // labels to raw "5800 MHz". let catalog = $state([]); + // ── Open-practice format (open-practice Slice 2) ───────────────────────────────────────────── + // The casual **open-practice** format runs a single open heat over a set of active **channels** + // (timer node seats) rather than pilots — its field is seeded `AllChannels { channels }` (node + // indices), with no classes. So when this format is chosen the normal class/seeding inputs are + // swapped for an active-channels picker driven by the event's **primary timer** (its `node_count` + // seats, each labelled by its configured `available_channels[i]` channel). The picker reflects an + // edited round's existing `AllChannels` selection. + const OPEN_PRACTICE = 'open_practice'; + // The effective primary timer (its node_count + available_channels lay out the picker). + const primaryTimer = $derived(session.primaryTimer); + // One pickable node seat: its index, the raw MHz it's configured to (if any), and its label. + interface NodeSeat { + node: number; + mhz: number | undefined; + label: string; + } + const timerNodes = $derived(buildTimerNodes(primaryTimer, catalog)); + function buildTimerNodes(timer: Timer | undefined, cat: ChannelCatalogEntry[]): NodeSeat[] { + if (!timer) return []; + const avail = timer.available_channels ?? []; + const count = Math.max(0, Math.round(timer.node_count ?? 0)); + const seats: NodeSeat[] = []; + for (let i = 0; i < count; i++) { + seats.push({ node: i, mhz: avail[i], label: nodeChannelLabel(i, avail, cat) }); + } + return seats; + } + // The chosen active node indices (the AllChannels payload), as a set for toggle ergonomics. + let selectedNodes = $state>(new Set()); + function toggleNode(node: number) { + const next = new Set(selectedNodes); + if (next.has(node)) next.delete(node); + else next.add(node); + selectedNodes = next; + } + // The event's rounds, read straight off `currentEvent` (the session re-homes it after each write // so this stays live). Display order is definition order. const rounds = $derived(session.currentEvent?.rounds ?? []); @@ -392,6 +429,13 @@ let startMaxMs = $state(5000); // randomized start hold: longest let graceSeconds = $state(3); // grace window after the win condition, in seconds + // Open-practice format (open-practice Slice 2): swaps the class/seeding inputs for the + // active-channels picker, and is submittable on a label + at least one active channel (no classes). + const isOpenPractice = $derived(format === OPEN_PRACTICE); + const canSubmitOpenPractice = $derived( + isOpenPractice && label.trim().length > 0 && selectedNodes.size > 0 + ); + // Whether the eligible-classes pick reads as open/practice (all selected) or a class round (one). const classHint = $derived( selectedClasses.size === 0 @@ -465,6 +509,7 @@ seedKind = 'FromRoster'; seedSource = ''; seedTopN = 8; + selectedNodes = new Set(); params = []; channelMode = 'PerHeat'; addParamKey = ''; @@ -512,9 +557,10 @@ seedSource = seed.FromRanking.source_round; seedTopN = seed.FromRanking.top_n; } else { - // AllChannels (open-practice format) — its active-channels picker is Slice 2; the Rounds - // editor here just falls back to the roster-seeded view so it stays type-safe meanwhile. + // AllChannels (open-practice format): reflect the round's active node selection into the + // channels picker (the format swaps the class/seeding inputs for it below). seedKind = 'FromRoster'; + selectedNodes = new Set(seed.AllChannels.channels); } // Heat-lifecycle config (Slice 3): staging timer (split mm:ss), the randomized start window, and @@ -601,23 +647,28 @@ // The form is submittable once it has a label, at least one eligible class, a format, and — when // seeding from a ranking — a chosen source round. const canSubmit = $derived( - label.trim().length > 0 && - selectedClasses.size > 0 && - format.length > 0 && - (seedKind === 'FromRoster' || (seedKind === 'FromRanking' && !!seedSource)) + isOpenPractice + ? canSubmitOpenPractice + : label.trim().length > 0 && + selectedClasses.size > 0 && + format.length > 0 && + (seedKind === 'FromRoster' || (seedKind === 'FromRanking' && !!seedSource)) ); async function submit() { if (saving || !canSubmit) return; saving = true; - // Eligible classes in the event's selection order (a stable, sensible order). + // Eligible classes in the event's selection order (a stable, sensible order). Open practice is + // class-less and seeds from the active channels (node indices) instead. const req: NewRoundReq = { label: label.trim(), - classes: eventClassIds.filter((id) => selectedClasses.has(id)), + classes: isOpenPractice ? [] : eventClassIds.filter((id) => selectedClasses.has(id)), format, params: buildParams(), win_condition: buildWinCondition(), - seeding: buildSeeding(), + seeding: isOpenPractice + ? { AllChannels: { channels: [...selectedNodes].sort((a, b) => a - b) } } + : buildSeeding(), channel_mode: channelMode, staging_timer_secs: buildStagingSecs(), start_procedure: buildStartProcedure(), @@ -681,15 +732,20 @@ subtitle="Define this event's rounds — eligible classes, format, win condition, and seeding. Rounds are added as you go." > {#snippet actions()} - {/snippet} - {#if eventClasses.length === 0} + {#if eventClasses.length === 0 && !primaryTimer}

    This event selects no classes yet. Pick classes in the Classes stage first — - a round runs for one or more of them. + a round runs for one or more of them (or set a timer to run open practice).

    {:else if rounds.length === 0}

    No rounds yet. Add the first round to get going.

    @@ -739,21 +795,23 @@ - -
    - {#each eventClasses as cls (cls.id)} - - {/each} -
    -
    + {#if !isOpenPractice} + +
    + {#each eventClasses as cls (cls.id)} + + {/each} +
    +
    + {/if}
    @@ -784,17 +842,63 @@ {/if}
    - - - + {#if isOpenPractice} + + + {#if timerNodes.length === 0} +

    + {#if !primaryTimer} + No primary timer for this event. Set a timer in the Timers stage — + open practice runs over its channels. + {:else} + {primaryTimer.name} has no node seats configured. Set its + channels in the Timers stage first. + {/if} +

    + {:else} +
    + {#each timerNodes as seat (seat.node)} + + {/each} +
    + {/if} +
    + {/if} + + {#if !isOpenPractice} + + + + {/if}
    Start & timing @@ -851,36 +955,38 @@
    - - - + {#if !isOpenPractice} + + + - {#if seedKind === 'FromRanking'} -
    - - {#if sourceCandidates.length === 0} -

    Add another round first to seed from its ranking.

    - {:else} - - {/if} -
    - - - -
    + {#if seedKind === 'FromRanking'} +
    + + {#if sourceCandidates.length === 0} +

    Add another round first to seed from its ranking.

    + {:else} + + {/if} +
    + + + +
    + {/if} {/if} (session.primaryTimer?.available_channels ?? []); + + // One per-channel board row: its node index, channel label, laps, last lap, and best lap (µs). + interface ChannelRow { + node: number; + ref: CompetitorRef; + label: string; + laps: number; + lastLapMicros: number | undefined; + bestLapMicros: number | undefined; + } + // Best lap isn't carried on the live stream (`PilotProgress` is laps + last lap), so the board + // tracks it client-side: the min `last_lap_micros` observed per channel over the run. It resets + // whenever the heat changes (a fresh practice run / Reset starts a clean board — the backend + // clears its in-memory laps on the new heat, and this mirrors that). + let bestByRef = $state>(new Map()); + let bestForHeat = $state(undefined); + $effect(() => { + // On a heat change, wipe the accumulated bests (matches the backend clearing its lap store). + if (heat !== bestForHeat) { + bestForHeat = heat; + bestByRef = new Map(); + } + if (!isOpenPractice) return; + let changed = false; + const next = new Map(bestByRef); + for (const p of live?.progress ?? []) { + const last = p.last_lap_micros; + if (last === undefined || last === null) continue; + const prev = next.get(p.competitor); + if (prev === undefined || last < prev) { + next.set(p.competitor, last); + changed = true; + } + } + if (changed) bestByRef = next; + }); + + // The board rows, in node order: every active `node-{i}` channel with its live laps. A channel + // with no laps yet still shows (a quiet seat reads "0 laps"). + const channelRows = $derived(buildChannelRows(live, availableChannels)); + function buildChannelRows(state: LiveRaceState | undefined, avail: number[]): ChannelRow[] { + const byRef = new Map( + (state?.progress ?? []).map((p) => [p.competitor, p]) + ); + const refs = state?.active_pilots ?? []; + const rows: ChannelRow[] = []; + for (const ref of refs) { + const node = nodeIndexOf(ref); + if (node === undefined) continue; // Not an open-practice channel ref — skip defensively. + const p = byRef.get(ref); + rows.push({ + node, + ref, + label: nodeChannelLabel(node, avail, catalog), + laps: p?.laps_completed ?? 0, + lastLapMicros: p?.last_lap_micros ?? undefined, + bestLapMicros: bestByRef.get(ref) + }); + } + return rows.sort((a, b) => a.node - b.node); + } + + // ── Reset / new practice run (open-practice Slice 2) ────────────────────────────────────────── + // A fresh run re-fills the open-practice round to mint a new heat, which clears the backend's + // in-memory laps (per the format) — wiping the board between practice sessions. The new heat + // arrives on the live stream; the best-lap tracker resets on the heat change (above). + let resetting = $state(false); + async function startFreshRun() { + const roundId = currentRound?.id; + if (!roundId || resetting) return; + resetting = true; + try { + const ack = await session.fillRound(roundId); + if (!ack.ok) return; // The error banner surfaces session.lastCommandError. + toast.success('Fresh practice run — board cleared.'); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + resetting = false; + } + } + // ── Staging countdown (heat-lifecycle Slice 3) ─────────────────────────────────────────────── // While the heat is Staged, count down from the round's staging window. Informational only — no // auto-advance; it goes negative (over-time) past zero so the RD sees a field that isn't ready. @@ -280,40 +381,87 @@
    - {#if heat && lineup.length > 0} - -
      - {#each lineup as ref (ref)} -
    • - {names[ref] ?? ref} - - {currentChannels.get(ref) ?? '—'} - -
    • - {/each} -
    - {#if !hasChannels} -

    No channels assigned (a sim heat tunes none).

    - {/if} -
    - {/if} + {#if isOpenPractice} + + + {#snippet actions()} + + {/snippet} -
    - - {#if live} - + {#if !heat} +

    — no practice heat on the timer —

    + {:else if channelRows.length === 0} +

    No active channels — fill the round to start a practice run.

    {:else} -

    Waiting for a live heat…

    +
      + {#each channelRows as row (row.ref)} +
    • + + {row.label} + + {row.laps} + laps + + + Last + {formatMicros(row.lastLapMicros)} + + + Best + {formatMicros(row.bestLapMicros)} + +
    • + {/each} +
    {/if}
    - - {#if liveResult} - - {:else} -

    No laps yet.

    - {/if} -
    -
    + {:else} + {#if heat && lineup.length > 0} + +
      + {#each lineup as ref (ref)} +
    • + {names[ref] ?? ref} + + {currentChannels.get(ref) ?? '—'} + +
    • + {/each} +
    + {#if !hasChannels} +

    No channels assigned (a sim heat tunes none).

    + {/if} +
    + {/if} + +
    + + {#if live} + + {:else} +

    Waiting for a live heat…

    + {/if} +
    + + {#if liveResult} + + {:else} +

    No laps yet.

    + {/if} +
    +
    + {/if} diff --git a/frontend/apps/rd-console/src/screens/EventTimers.svelte b/frontend/apps/rd-console/src/screens/EventTimers.svelte index 7d73dc6..9dba505 100644 --- a/frontend/apps/rd-console/src/screens/EventTimers.svelte +++ b/frontend/apps/rd-console/src/screens/EventTimers.svelte @@ -8,14 +8,21 @@ * a checkbox per row bound to a local working set, saved to `EventMeta.timers` via * `setEventTimers`. New events and Practice default to the built-in Mock. * - * Selection edits a local working set as the RD ticks boxes; "Save" pushes it (the session then - * re-homes `currentEvent` with the server's response). After any create/edit/delete the manager - * reloads and hands back the fresh list, so the working set is reconciled (a removed timer drops - * out of the selection; a freshly added one is simply available to tick). + * Selection edits a local working set as the RD ticks boxes; each tick **auto-saves** the full + * selection (debounced, wholesale `setEventTimers`) — there is no explicit Save button. The + * session then re-homes `currentEvent` with the server's response. After any create/edit/delete + * the manager reloads and hands back the fresh list, so the working set is reconciled (a removed + * timer drops out of the selection; a freshly added one is simply available to tick). + * + * Auto-save is **optimistic**: the checkbox flips instantly and the wholesale-set lands in the + * background; on a save error the change is reverted (re-seeded from the event) and surfaced. + * Because every save sends the *entire* current selection (not a delta), coalescing rapid clicks + * into one trailing save is safe last-write-wins. */ import { Button, Card, toast } from '@gridfpv/components'; import type { Timer, TimerId } from '@gridfpv/types'; import type { Session } from '../lib/session.svelte.js'; + import { AutoSaver } from '../lib/autosave.js'; import TimerManager from './TimerManager.svelte'; let { session }: { session: Session } = $props(); @@ -23,12 +30,11 @@ let manager = $state(undefined); // The registry list (kept in sync by the manager via `bind:timers`) and the working selection - // (a set of timer ids), seeded from the event and edited locally until the RD saves. We snapshot - // the event's saved selection so "Save"/"changed" can compare. + // (a set of timer ids), seeded from the event. Toggling a box edits the set optimistically and + // schedules a debounced save; we snapshot the event's saved selection so a failed save can revert. let timers = $state([]); let selected = $state>(new Set()); let savedSelection = $state([]); - let saving = $state(false); function syncFromEvent() { const ids = session.currentEvent?.timers ?? []; @@ -53,45 +59,45 @@ selected = next; } + // The ids to save, in the registry's listed order (a stable, sensible order). + const orderedSelection = $derived(timers.filter((t) => selected.has(t.id)).map((t) => t.id)); + + // ── Auto-save (debounced, optimistic, wholesale `setEventTimers`) ────────── + const autosaver = new AutoSaver(); + function toggle(id: TimerId) { + // Optimistic flip first so the checkbox reflects the click instantly. const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); + // An event must keep at least one timer — refuse to auto-clear the last one (revert + nudge). + if (next.size === 0) { + toast.error('An event needs at least one timer.'); + // Re-seed from the event so the bound checkbox reliably snaps back to checked (the native + // click already flipped the DOM; re-seeding restores the saved selection). + syncFromEvent(); + return; + } selected = next; + scheduleSave(); } - // The ids to save, in the registry's listed order (a stable, sensible order). - const orderedSelection = $derived(timers.filter((t) => selected.has(t.id)).map((t) => t.id)); - - const changed = $derived( - orderedSelection.length !== savedSelection.length || - orderedSelection.some((id, i) => id !== savedSelection[i]) - ); - - async function save() { - if (saving || !changed) return; - if (orderedSelection.length === 0) { - toast.error('Select at least one timer for the event.'); - return; - } - saving = true; - try { - const updated = await session.setEventTimers(orderedSelection); - if (!updated) { + // Schedule a single trailing save of the full selection. The payload is computed at flush time so + // it reflects the latest selection even if more boxes were ticked during the debounce window. + function scheduleSave() { + autosaver.schedule('timers', { + compute: () => orderedSelection, + save: (ids) => session.setEventTimers(ids), + onUnsaved: () => { toast.info('A control token is required to set the event’s timers.'); - return; + syncFromEvent(); + }, + onError: (e) => { + // Revert the optimistic change to the last-saved selection and surface the failure. + syncFromEvent(); + toast.error(e instanceof Error ? e.message : String(e)); } - syncFromEvent(); - toast.success('Event timers saved.'); - } catch (e) { - toast.error(e instanceof Error ? e.message : String(e)); - } finally { - saving = false; - } - } - - function reset() { - syncFromEvent(); + }); } // ── Primary / alternate roles (issue #112) ──────────────────────────────── @@ -167,16 +173,8 @@ {#snippet listFooter()}
    - {orderedSelection.length} selected + {orderedSelection.length} selected · saved automatically -
    - {#if changed} - - {/if} - -
    {/snippet}
    @@ -301,8 +299,4 @@ font-size: var(--gf-font-size-xs); color: var(--gf-text-muted); } - .foot-actions { - display: flex; - gap: var(--gf-space-2); - } diff --git a/frontend/apps/rd-console/tests/EventClassesRoster.test.ts b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts index 264b1e6..2e0cef2 100644 --- a/frontend/apps/rd-console/tests/EventClassesRoster.test.ts +++ b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts @@ -122,19 +122,21 @@ describe('EventClassesRoster — per-pilot channel (sourced from the primary tim expect(options).toContain('Raceband R2 · 5695 MHz'); expect(options).toContain('No channel'); - // Assign Ace a channel and save the placement → the wire carries a MemberSlot with the channel. + // There is no Save button — placement auto-saves. Assign Ace a channel; the debounced + // `setClassMembership` lands on its own carrying a MemberSlot with the channel. + expect(screen.queryByRole('button', { name: 'Save placement' })).toBeNull(); await fireEvent.change(sel, { target: { value: '5658' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save placement' })); - - await waitFor(() => expect(setClassMembershipImpl).toHaveBeenCalledTimes(1)); - // The wire carries the class id + MemberSlots — both auto-filled pilots, Ace with his channel, - // Bee channel-less (a single-class event auto-fills every roster pilot into the lone class). - expect(setClassMembershipImpl).toHaveBeenCalledWith( - 'http://d.local', - 'e1', - 'open', - [{ pilot: 'p1', channel: 5658 }, { pilot: 'p2' }], - 'tok' + + // The latest wire call carries the class id + MemberSlots — both auto-filled pilots, Ace with + // his channel, Bee channel-less (a single-class event auto-fills every roster pilot). + await waitFor(() => + expect(setClassMembershipImpl).toHaveBeenCalledWith( + 'http://d.local', + 'e1', + 'open', + [{ pilot: 'p1', channel: 5658 }, { pilot: 'p2' }], + 'tok' + ) ); }); @@ -178,6 +180,59 @@ describe('EventClassesRoster — select all / unselect all pilots', () => { }); }); +describe('EventClassesRoster — auto-save (no Save buttons)', () => { + // A two-class event so toggling a class is a non-empty selection both ways. + const TWO_CLASS: EventMeta = { ...SINGLE, classes: ['open'] }; + + it('toggling a class auto-saves the full selection (debounced), no Save click', async () => { + const setEventClassesImpl = vi.fn(async () => ({ ...TWO_CLASS, classes: ['open', 'spec'] })); + const { session } = makeTestSession({ + ...impls({ setEventClassesImpl }), + event: TWO_CLASS + }); + render(EventClassesRoster, { session }); + + // No Save buttons for the selection boxes. + const spec = (await screen.findByLabelText('Select Spec')) as HTMLInputElement; + expect(screen.queryByRole('button', { name: 'Save classes' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save roster' })).toBeNull(); + + // Tick Spec → optimistic flip, then a debounced wholesale save of the full class selection. + await fireEvent.click(spec); + expect(spec.checked).toBe(true); + await waitFor(() => + expect(setEventClassesImpl).toHaveBeenCalledWith( + 'http://d.local', + 'e1', + ['open', 'spec'], + 'tok' + ) + ); + }); + + it('reverts a roster toggle optimistically when the save fails', async () => { + const EMPTY_ROSTER: EventMeta = { ...SINGLE, roster: [] }; + const setEventRosterImpl = vi.fn(async () => { + throw new Error('boom'); + }); + const { session } = makeTestSession({ + ...impls({ setEventRosterImpl }), + event: EMPTY_ROSTER + }); + render(EventClassesRoster, { session }); + + const ace = (await screen.findByLabelText('Roster Ace')) as HTMLInputElement; + await fireEvent.click(ace); // optimistically present + expect(ace.checked).toBe(true); + + // The failed save reverts the roster to the last-saved (empty) state. + await waitFor(() => expect(setEventRosterImpl).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect((screen.getByLabelText('Roster Ace') as HTMLInputElement).checked).toBe(false) + ); + }); +}); + describe('EventClassesRoster — auto-assign channels', () => { it('fills every placed pilot from the channel pool (round-robin) and saves each class', async () => { const setClassMembershipImpl = vi.fn(async () => SINGLE); @@ -199,16 +254,17 @@ describe('EventClassesRoster — auto-assign channels', () => { expect(beeSel.value).toBe('5695'); // Membership saved for the lone class, both MemberSlots carrying their assigned channel. - await waitFor(() => expect(setClassMembershipImpl).toHaveBeenCalledTimes(1)); - expect(setClassMembershipImpl).toHaveBeenCalledWith( - 'http://d.local', - 'e1', - 'open', - [ - { pilot: 'p1', channel: 5658 }, - { pilot: 'p2', channel: 5695 } - ], - 'tok' + await waitFor(() => + expect(setClassMembershipImpl).toHaveBeenLastCalledWith( + 'http://d.local', + 'e1', + 'open', + [ + { pilot: 'p1', channel: 5658 }, + { pilot: 'p2', channel: 5695 } + ], + 'tok' + ) ); }); diff --git a/frontend/apps/rd-console/tests/EventSetupWizard.test.ts b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts index bc124fd..1905705 100644 --- a/frontend/apps/rd-console/tests/EventSetupWizard.test.ts +++ b/frontend/apps/rd-console/tests/EventSetupWizard.test.ts @@ -95,6 +95,34 @@ describe('EventSetupWizard (guided first-pass stepper)', () => { expect(await screen.findByRole('heading', { name: 'Rounds', level: 3 })).toBeInTheDocument(); }); + it('advances through every stage with only Next — no Save button anywhere (auto-save)', async () => { + const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); + render(EventSetupWizard, { session, open: true }); + + const noSaveButtons = () => { + // The embedded stages auto-save; none surfaces a selection Save button in the wizard. + expect(screen.queryByRole('button', { name: 'Save classes' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save roster' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save placement' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save selection' })).toBeNull(); + }; + + // Step 1: Classes & Roster. + await screen.findByRole('button', { name: /^Classes/ }); + noSaveButtons(); + // Next → Timers. + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('heading', { name: 'Timers for this event' }); + noSaveButtons(); + // Next → First round. + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('heading', { name: 'Rounds', level: 3 }); + noSaveButtons(); + // Next → Review, finishing with only Next clicks. + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByRole('button', { name: 'Finish setup' })).toBeInTheDocument(); + }); + it('Skip advances a step just like Next (steps are optional)', async () => { const { session } = makeTestSession({ ...impls(), event: EMPTY_EVENT }); render(EventSetupWizard, { session, open: true }); diff --git a/frontend/apps/rd-console/tests/EventTimers.test.ts b/frontend/apps/rd-console/tests/EventTimers.test.ts index 130644a..facf78d 100644 --- a/frontend/apps/rd-console/tests/EventTimers.test.ts +++ b/frontend/apps/rd-console/tests/EventTimers.test.ts @@ -56,25 +56,23 @@ describe('EventTimers (in-event CRUD + selection)', () => { const rhBox = screen.getByLabelText('Use Track RH') as HTMLInputElement; expect(mockBox.checked).toBe(true); expect(rhBox.checked).toBe(false); - // No change yet → Save disabled. - expect( - (screen.getByRole('button', { name: 'Save selection' }) as HTMLButtonElement).disabled - ).toBe(true); + // Selection auto-saves — there is no explicit Save button. + expect(screen.queryByRole('button', { name: 'Save selection' })).toBeNull(); + expect(screen.getByText(/saved automatically/)).toBeInTheDocument(); }); - it('saves the working selection in the registry-listed order on change', async () => { + it('auto-saves the working selection (debounced) in registry-listed order on a toggle', async () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); const setEventTimersImpl = vi.fn(async () => ({ ...EVENT, timers: ['mock', 'rh-1'] })); const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); render(EventTimers, { session }); const rhBox = (await screen.findByLabelText('Use Track RH')) as HTMLInputElement; + // Optimistic: the box flips instantly, no Save click. await fireEvent.click(rhBox); + expect(rhBox.checked).toBe(true); - const save = screen.getByRole('button', { name: 'Save selection' }) as HTMLButtonElement; - expect(save.disabled).toBe(false); - await fireEvent.click(save); - + // The debounced save lands on its own with the FULL selection in registry order. await waitFor(() => expect(setEventTimersImpl).toHaveBeenCalledTimes(1)); expect(setEventTimersImpl).toHaveBeenCalledWith( 'http://d.local', @@ -84,20 +82,56 @@ describe('EventTimers (in-event CRUD + selection)', () => { ); }); - it('blocks saving an empty selection', async () => { + it('coalesces rapid toggles into one trailing save carrying the latest selection', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const setEventTimersImpl = vi.fn(async () => EVENT); + const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const rhBox = (await screen.findByLabelText('Use Track RH')) as HTMLInputElement; + // Tick then untick within the debounce window → one save, back at {mock}. + await fireEvent.click(rhBox); + await fireEvent.click(rhBox); + + await waitFor(() => expect(setEventTimersImpl).toHaveBeenCalledTimes(1)); + expect(setEventTimersImpl).toHaveBeenCalledWith('http://d.local', 'e1', ['mock'], 'tok'); + }); + + it('refuses to auto-clear the last timer (an event needs at least one)', async () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); const setEventTimersImpl = vi.fn(async () => EVENT); const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); render(EventTimers, { session }); const mockBox = (await screen.findByLabelText('Use Mock')) as HTMLInputElement; - await fireEvent.click(mockBox); // unselect the only selected → empty - await fireEvent.click(screen.getByRole('button', { name: 'Save selection' })); + await fireEvent.click(mockBox); // unselect the only selected → would be empty - // The empty selection is rejected client-side; no protocol call goes out. + // The toggle is refused: the selection still holds the one timer (the count line reflects it) + // and no wholesale save goes out. + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + await new Promise((r) => setTimeout(r, 400)); expect(setEventTimersImpl).not.toHaveBeenCalled(); }); + it('reverts the optimistic toggle when the save fails', async () => { + const listTimersImpl = vi.fn(async () => [MOCK, RH]); + const setEventTimersImpl = vi.fn(async () => { + throw new Error('boom'); + }); + const { session } = makeTestSession({ listTimersImpl, setEventTimersImpl, event: EVENT }); + render(EventTimers, { session }); + + const rhBox = (await screen.findByLabelText('Use Track RH')) as HTMLInputElement; + await fireEvent.click(rhBox); // optimistically checked + expect(rhBox.checked).toBe(true); + + // The failed save reverts the selection to the last-saved state (RH unchecked again). + await waitFor(() => expect(setEventTimersImpl).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect((screen.getByLabelText('Use Track RH') as HTMLInputElement).checked).toBe(false) + ); + }); + it('adds a timer from inside the event (shared management)', async () => { const created: Timer = { id: 'fast-x', diff --git a/frontend/apps/rd-console/tests/autosave.test.ts b/frontend/apps/rd-console/tests/autosave.test.ts new file mode 100644 index 0000000..66045fe --- /dev/null +++ b/frontend/apps/rd-console/tests/autosave.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AutoSaver } from '../src/lib/autosave.js'; + +/** + * AutoSaver is the per-target debounced saver behind the Save-less selection stages. These tests + * pin its contract: it coalesces rapid changes per target into one trailing save, computes the + * payload at flush time (so it captures the latest state), saves different targets independently, + * and routes falsy results / thrown errors to the right callback. + */ +describe('AutoSaver', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('debounces rapid schedules on one target into a single trailing save', async () => { + const save = vi.fn(async () => ({ ok: true })); + const saver = new AutoSaver(300); + + // Three quick changes mutate a shared "working selection"; compute reads it at flush time, so + // the single trailing save carries the LATEST value — not a stale snapshot from scheduling. + let selection = 'a'; + const compute = () => selection; + saver.schedule('classes', { compute, save }); + selection = 'ab'; + saver.schedule('classes', { compute, save }); + selection = 'abc'; + saver.schedule('classes', { compute, save }); + + expect(save).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(300); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith('abc'); + }); + + it('saves different targets independently', async () => { + const save = vi.fn(async () => ({ ok: true })); + const saver = new AutoSaver(300); + + saver.schedule('classes', { compute: () => 'c', save }); + saver.schedule('roster', { compute: () => 'r', save }); + + await vi.advanceTimersByTimeAsync(300); + expect(save).toHaveBeenCalledTimes(2); + expect(save).toHaveBeenCalledWith('c'); + expect(save).toHaveBeenCalledWith('r'); + }); + + it('routes a falsy save result to onUnsaved (not onError)', async () => { + const onUnsaved = vi.fn(); + const onError = vi.fn(); + const saver = new AutoSaver(300); + + saver.schedule('t', { compute: () => 1, save: async () => undefined, onUnsaved, onError }); + await vi.advanceTimersByTimeAsync(300); + + expect(onUnsaved).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('routes a thrown save to onError', async () => { + const onError = vi.fn(); + const saver = new AutoSaver(300); + + saver.schedule('t', { + compute: () => 1, + save: async () => { + throw new Error('boom'); + }, + onError + }); + await vi.advanceTimersByTimeAsync(300); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + }); + + it('cancel drops a pending save; flush runs it immediately', async () => { + const save = vi.fn(async () => ({ ok: true })); + const saver = new AutoSaver(300); + + saver.schedule('a', { compute: () => 'a', save }); + saver.cancel('a'); + await vi.advanceTimersByTimeAsync(300); + expect(save).not.toHaveBeenCalled(); + + saver.schedule('b', { compute: () => 'b', save }); + saver.flush('b'); + await vi.advanceTimersByTimeAsync(0); + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith('b'); + }); +}); diff --git a/frontend/e2e/classes-roster-boxes.spec.ts b/frontend/e2e/classes-roster-boxes.spec.ts index 0c5cdbc..176ee2e 100644 --- a/frontend/e2e/classes-roster-boxes.spec.ts +++ b/frontend/e2e/classes-roster-boxes.spec.ts @@ -108,16 +108,19 @@ test('three labelled boxes collapse + persist; select-all roster; auto-assign ch }); } - // ── 2. Re-open the boxes; Select all in the Pilots box and save the roster. ──────────────────── + // ── 2. Re-open the boxes; Select all in the Pilots box — the roster AUTO-SAVES (no Save button). ─ await boxToggle(page, 'Pilots').click(); await expect(boxToggle(page, 'Pilots')).toHaveAttribute('aria-expanded', 'true'); + const rosterSaved = page.waitForResponse( + (r) => /\/events\/.+\/roster$/.test(r.url()) && r.request().method() === 'PUT' + ); await page.getByRole('button', { name: 'Select all', exact: true }).click(); // Every directory pilot's roster checkbox is now ticked. for (const callsign of PILOTS) { await expect(page.getByRole('checkbox', { name: `Roster ${callsign}` })).toBeChecked(); } - await page.getByRole('button', { name: 'Save roster' }).click(); + await rosterSaved; // Single-class auto-fill places every rostered pilot — their channel selectors appear. for (const callsign of PILOTS) { await expect(page.getByLabel(`Channel for ${callsign}`)).toBeVisible({ timeout: 15_000 }); diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index 512c0a5..92e19a4 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -10,8 +10,9 @@ * the form has no org source picker (new classes are `Custom`), and the created row shows the * `Custom` badge and keeps Edit/Remove; finally **remove** it. * 2. **In-event selection of a built-in** — enter an event (Practice), open the workspace's - * **Classes** tab, **check a built-in** (`Open Class`) into this event's selection and **Save**; - * a reload resumes into the event with the built-in still selected. Cleans up (uncheck + save). + * **Classes** tab, **check a built-in** (`Open Class`) into this event's selection — which + * **auto-saves** (no Save button); a reload resumes into the event with the built-in still + * selected. Cleans up (uncheck auto-saves). * * Every step is a real click/input in headless chromium on the real `POST/DELETE /classes` + * `PUT /events/{id}/classes` paths — nothing mocked. Importing `test`/`expect` from @@ -121,19 +122,22 @@ test('RD selects a built-in class onto the event and it persists', async ({ page }); await expect(page.getByText(/selected for this event/i)).toBeVisible(); - // ── A built-in is selectable like any class: check "Open Class" into the event, then Save ──── + // ── A built-in is selectable like any class: checking "Open Class" AUTO-SAVES (no Save button) ─ const list = page.getByRole('list', { name: 'Class directory' }); const row = list.getByRole('listitem').filter({ hasText: 'Open Class' }); await expect(row).toBeVisible({ timeout: 15_000 }); const box = row.getByRole('checkbox', { name: 'Select Open Class' }); - // Start from a known state (a prior run may have left it checked); ensure checked. - if (!(await box.isChecked())) await box.check(); + // Start from a known state (a prior run may have left it checked); ensure checked, waiting for the + // debounced `PUT …/classes` to land when we actually toggle it on. + if (!(await box.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await box.check(); + await classesSaved; + } await expect(box).toBeChecked(); - await page.getByRole('button', { name: 'Save classes' }).click(); await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); // ── It persisted on the Director: a reload resumes into the event with the built-in checked ── await page.reload(); @@ -150,14 +154,14 @@ test('RD selects a built-in class onto the event and it persists', async ({ page timeout: 15_000 }); - // ── Clean up: uncheck + Save so the shared Director's event goes back to an empty selection ── + // ── Clean up: uncheck (auto-saves) so the shared Director's event goes back to an empty selection ─ const boxAfter = rowAfter.getByRole('checkbox', { name: 'Select Open Class' }); + const classesCleared = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); await boxAfter.uncheck(); await expect(boxAfter).not.toBeChecked(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + await classesCleared; }); test('RD hides a class on the Classes page; it drops out of the event picker; unhide restores it', async ({ diff --git a/frontend/e2e/heat-slice3.spec.ts b/frontend/e2e/heat-slice3.spec.ts index 3d0b2ad..01e2566 100644 --- a/frontend/e2e/heat-slice3.spec.ts +++ b/frontend/e2e/heat-slice3.spec.ts @@ -84,11 +84,14 @@ test('Slice 3 surfaces: staging countdown, arming state, and the round config fo .getByRole('listitem') .filter({ hasText: 'Open Class' }) .getByRole('checkbox', { name: 'Select Open Class' }); - if (!(await classBox.isChecked())) await classBox.check(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } + await expect(classBox).toBeChecked(); await page .getByRole('navigation', { name: 'Screens' }) @@ -120,10 +123,10 @@ test('Slice 3 surfaces: staging countdown, arming state, and the round config fo timeout: 15_000 }); if (await classBox.isChecked()) { + const classesCleared = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); await classBox.uncheck(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + await classesCleared; } }); diff --git a/frontend/e2e/roster.spec.ts b/frontend/e2e/roster.spec.ts index 391498f..7171b20 100644 --- a/frontend/e2e/roster.spec.ts +++ b/frontend/e2e/roster.spec.ts @@ -3,10 +3,10 @@ * * A person enters an event (Practice), opens the workspace's **Registration** screen (now the * EventRoster), and — without leaving the event — **registers a brand-new pilot inline**, then - * **checks it into this event's roster** and **saves**. The roster is asserted to have persisted on - * the Director (a reload resumes into the event with the pilot still checked). Finally the pilot is - * **unchecked + saved** (roster shrinks) and **removed** from the directory — cleaning up after - * itself, since the worker's Director is shared. + * **checks it into this event's roster** — which **auto-saves** (no Save button). The roster is + * asserted to have persisted on the Director (a reload resumes into the event with the pilot still + * checked). Finally the pilot is **unchecked** (roster shrinks, auto-saved) and **removed** from the + * directory — cleaning up after itself, since the worker's Director is shared. * * Every step is a real click/input in headless chromium on the real `POST /pilots` + * `PUT /events/{id}/roster` paths — nothing mocked. Importing `test`/`expect` from @@ -67,13 +67,15 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ const box = row.getByRole('checkbox', { name: `Roster ${CALLSIGN}` }); await expect(box).not.toBeChecked(); - // ── Check it into the event roster, then Save ─────────────────────────────────────────────── + // ── Check it into the event roster — this AUTO-SAVES (no Save button). Wait for the debounced + // `PUT /events/{id}/roster` to land before asserting persistence. ── + const rosterSaved = page.waitForResponse( + (r) => /\/events\/.+\/roster$/.test(r.url()) && r.request().method() === 'PUT' + ); await box.check(); await expect(box).toBeChecked(); - await page.getByRole('button', { name: 'Save roster' }).click(); + await rosterSaved; await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - // After a successful save there is nothing pending, so Save goes disabled again. - await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); // ── It persisted on the Director: a reload resumes into the event with the pilot still checked. await page.reload(); @@ -90,12 +92,14 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ timeout: 15_000 }); - // ── Uncheck + Save: the roster shrinks back (the remove side of the toggle) ────────────────── + // ── Uncheck: the roster shrinks back (the remove side of the toggle) — auto-saved ──────────── const boxAfter = rowAfter.getByRole('checkbox', { name: `Roster ${CALLSIGN}` }); + const rosterShrank = page.waitForResponse( + (r) => /\/events\/.+\/roster$/.test(r.url()) && r.request().method() === 'PUT' + ); await boxAfter.uncheck(); await expect(boxAfter).not.toBeChecked(); - await page.getByRole('button', { name: 'Save roster' }).click(); - await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); + await rosterShrank; // ── Clean up: remove the pilot from the directory (the worker's Director is shared) ────────── await rowAfter.getByRole('button', { name: 'Remove' }).click(); @@ -116,8 +120,9 @@ test('RD registers a pilot inline and checks it into the event roster', async ({ * Enter an event (Practice), select the built-in **Open Class** onto it, then in the same combined * stage: register a pilot inline and mark them present. Because exactly one class is selected the * pilot is **auto-placed** (no per-class checkbox); the RD assigns them a **channel** drawn from the - * primary timer's available channels and saves the placement — the key proof, which we assert - * **persists across a reload** (the channel seeds off `EventMeta.classes_membership`'s `MemberSlot`). + * primary timer's available channels — the channel placement **auto-saves** (no Save button), the + * key proof, which we assert **persists across a reload** (the channel seeds off + * `EventMeta.classes_membership`'s `MemberSlot`). * Cleans up after itself (the worker's Director is shared): unticks the class and removes the pilot. */ test('RD auto-fills a single class and assigns a pilot a channel (persists)', async ({ page }) => { @@ -153,12 +158,14 @@ test('RD auto-fills a single class and assigns a pilot a channel (persists)', as .getByRole('listitem') .filter({ hasText: 'Open Class' }); const classBox = classRow.getByRole('checkbox', { name: 'Select Open Class' }); - if (!(await classBox.isChecked())) await classBox.check(); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } await expect(classBox).toBeChecked(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); // ── Roster stage: register a pilot inline + mark present ────────────────────────────────────── await nav.getByRole('button', { name: 'Classes & Roster' }).click(); @@ -175,25 +182,29 @@ test('RD auto-fills a single class and assigns a pilot a channel (persists)', as const dir = page.getByRole('list', { name: 'Pilot directory' }); const pilotRow = dir.getByRole('listitem').filter({ hasText: CS }); await expect(pilotRow).toBeVisible({ timeout: 15_000 }); + const rosterSaved2 = page.waitForResponse( + (r) => /\/events\/.+\/roster$/.test(r.url()) && r.request().method() === 'PUT' + ); await pilotRow.getByRole('checkbox', { name: `Roster ${CS}` }).check(); - await page.getByRole('button', { name: 'Save roster' }).click(); - await expect(page.getByRole('button', { name: 'Save roster' })).toBeDisabled({ timeout: 15_000 }); + await rosterSaved2; // ── Single class → auto-fill: the present pilot is automatically placed (no checkbox); assign it - // a channel from the primary timer and save the placement. The channel IS the static binding. ── + // a channel from the primary timer. The channel placement AUTO-SAVES (the channel IS the static + // binding) — wait for the debounced `PUT …/membership` to land. ── const channelSel = page.getByLabel(`Channel for ${CS}`); await expect(channelSel).toBeVisible({ timeout: 15_000 }); // Pick the first real channel option (skip the "No channel" sentinel). const firstChannel = await channelSel.locator('option').nth(1).getAttribute('value'); - await channelSel.selectOption(firstChannel!); if (process.env.GRIDFPV_SHOTS) await page .getByRole('region', { name: 'Classes and roster' }) .screenshot({ path: `${process.env.GRIDFPV_SHOTS}/classes-roster-stage.png` }); - const savePlacement = page.getByRole('button', { name: 'Save placement' }); - await savePlacement.click(); + const placementSaved = page.waitForResponse( + (r) => /\/events\/.+\/membership$/.test(r.url()) && r.request().method() === 'PUT' + ); + await channelSel.selectOption(firstChannel!); + await placementSaved; await expect(page.getByRole('form', { name: 'Control token' })).toBeHidden(); - await expect(savePlacement).toBeDisabled({ timeout: 15_000 }); // ── The placement + channel persisted: a reload seeds the channel off // `EventMeta.classes_membership` (the MemberSlot's channel). ── @@ -211,11 +222,11 @@ test('RD auto-fills a single class and assigns a pilot a channel (persists)', as .filter({ hasText: 'Open Class' }); const classBoxAfter = classRowAfter.getByRole('checkbox', { name: 'Select Open Class' }); if (await classBoxAfter.isChecked()) { + const classesCleared = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); await classBoxAfter.uncheck(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + await classesCleared; } await nav.getByRole('button', { name: 'Classes & Roster' }).click(); diff --git a/frontend/e2e/rounds.spec.ts b/frontend/e2e/rounds.spec.ts index 957c142..649d052 100644 --- a/frontend/e2e/rounds.spec.ts +++ b/frontend/e2e/rounds.spec.ts @@ -55,11 +55,14 @@ test('RD defines a round (class, format, seeding), it persists, then edits and r .getByRole('listitem') .filter({ hasText: 'Open Class' }); const classBox = classRow.getByRole('checkbox', { name: 'Select Open Class' }); - if (!(await classBox.isChecked())) await classBox.check(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } + await expect(classBox).toBeChecked(); // ── Rounds & Heats tab → add a round ──────────────────────────────────────────────────────── await openTab(page, 'Rounds & Heats'); @@ -127,11 +130,11 @@ test('RD defines a round (class, format, seeding), it persists, then edits and r .filter({ hasText: 'Open Class' }) .getByRole('checkbox', { name: 'Select Open Class' }); if (await cleanupBox.isChecked()) { + const classesCleared = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); await cleanupBox.uncheck(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + await classesCleared; } }); @@ -162,11 +165,14 @@ test('RD adds a round with a guided param and a Static channel mode', async ({ .getByRole('listitem') .filter({ hasText: 'Open Class' }) .getByRole('checkbox', { name: 'Select Open Class' }); - if (!(await classBox.isChecked())) await classBox.check(); - await page.getByRole('button', { name: 'Save classes' }).click(); - await expect(page.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } + await expect(classBox).toBeChecked(); // Rounds & Heats → add a timed_qual round with a guided param + Static channel mode. await openTab(page, 'Rounds & Heats'); diff --git a/frontend/e2e/wizard.spec.ts b/frontend/e2e/wizard.spec.ts index fa24516..c287103 100644 --- a/frontend/e2e/wizard.spec.ts +++ b/frontend/e2e/wizard.spec.ts @@ -69,16 +69,19 @@ test('RD creates an event, the wizard walks the stages, and the workspace reflec await expect(wizard.getByText(`Set up · ${EVENT}`)).toBeVisible(); if (shots) await wizard.screenshot({ path: `${shots}/wizard-classes-roster.png` }); - // Step 1 — Classes & Roster: tick the built-in Open Class and save… + // Step 1 — Classes & Roster: tick the built-in Open Class — this AUTO-SAVES (no Save button). const classBox = wizard.getByRole('checkbox', { name: 'Select Open Class' }); await expect(classBox).toBeVisible({ timeout: 15_000 }); - if (!(await classBox.isChecked())) await classBox.check(); - await wizard.getByRole('button', { name: 'Save classes' }).click(); - await expect(wizard.getByRole('button', { name: 'Save classes' })).toBeDisabled({ - timeout: 15_000 - }); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } + await expect(classBox).toBeChecked(); - // …then, in the same combined step, add a brand-new pilot, mark present, and save the roster. + // …then, in the same combined step, add a brand-new pilot and mark present — the roster auto-saves. await wizard.getByRole('button', { name: '+ Add pilot' }).click(); const addForm = page.getByRole('form', { name: 'Add pilot' }); await expect(addForm).toBeVisible(); @@ -87,30 +90,30 @@ test('RD creates an event, the wizard walks the stages, and the workspace reflec await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); const rosterBox = wizard.getByRole('checkbox', { name: `Roster ${PILOT}` }); await expect(rosterBox).toBeVisible({ timeout: 15_000 }); - if (!(await rosterBox.isChecked())) await rosterBox.check(); - await wizard.getByRole('button', { name: 'Save roster' }).click(); - await expect(wizard.getByRole('button', { name: 'Save roster' })).toBeDisabled({ - timeout: 15_000 - }); - // With a single class the pilot is auto-placed (no "Place …" checkbox); save the placement. + if (!(await rosterBox.isChecked())) { + const rosterSaved = page.waitForResponse( + (r) => /\/events\/.+\/roster$/.test(r.url()) && r.request().method() === 'PUT' + ); + await rosterBox.check(); + await rosterSaved; + } + // With a single class the pilot is auto-placed (no "Place …" checkbox) and the membership + // auto-saves — the channel selector simply appears, no Save click needed. await expect(wizard.getByLabel(`Channel for ${PILOT}`)).toBeVisible({ timeout: 15_000 }); - await wizard.getByRole('button', { name: 'Save placement' }).click(); - await expect(wizard.getByRole('button', { name: 'Save placement' })).toBeDisabled({ - timeout: 15_000 - }); - // Step 2 — Timer & channels: the built-in Mock is selectable; ensure it's chosen. + // Step 2 — Timer & channels: the built-in Mock is selectable; ensure it's chosen (auto-saves). await wizard.getByRole('button', { name: 'Next', exact: true }).click(); await expect(wizard.getByRole('heading', { name: 'Timers for this event' })).toBeVisible(); const mockBox = wizard.getByRole('checkbox', { name: 'Use Mock' }); await expect(mockBox).toBeVisible({ timeout: 15_000 }); if (!(await mockBox.isChecked())) { + const timersSaved = page.waitForResponse( + (r) => /\/events\/.+\/timers$/.test(r.url()) && r.request().method() === 'PUT' + ); await mockBox.check(); - await wizard.getByRole('button', { name: 'Save selection' }).click(); - await expect(wizard.getByRole('button', { name: 'Save selection' })).toBeDisabled({ - timeout: 15_000 - }); + await timersSaved; } + await expect(mockBox).toBeChecked(); // Step 3 — First round: the add-round form is pre-opened; define one and add it. await wizard.getByRole('button', { name: 'Next', exact: true }).click(); From 80d3e2dcdf321573cdea12c8227edb1f22632221 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 20:36:07 +0000 Subject: [PATCH 141/362] fix: freeze race clock on Unofficial + keep header clock in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heat lifecycle is Running → Unofficial → Final. When an open-practice time limit fires the heat transitions Running → Unofficial (the result just isn't finalized yet), so the heat clock should freeze at the race-end instant there and stay frozen through Final. Root-cause check: the `Finished → Unofficial` phase rename (PR bef1d98) DID update `raceClock.svelte.ts` to freeze on Unofficial/Final, so the shared clock helper itself is correct on `devel`. The remaining defects were: - The persistent ContextHeader clock (#85) was gated `{#if running}`, so it *vanished* the instant the race closed instead of staying visible, frozen, at the race-end time — contradicting the live screen, which keeps showing the frozen clock. Now it shows the shared clock through Running/Unofficial/Final (hidden only pre-race, where it reads 0), mirroring the live HUD clock from the same `useRaceClock` source. - A stale "freezes on Finished/Final" comment in LiveRaceControl referencing the renamed-away `Finished` phase. Tests: new `raceClock.svelte.test.ts` pins the freeze (ticks on Running, freezes at the race-end value on Unofficial, stays frozen through Final, resets on a new heat / idle); new `ContextHeader.test.ts` pins the header clock staying visible and frozen through Unofficial/Final and hidden pre-race; the `race.spec.ts` e2e drives a real heat to Unofficial and asserts both the HUD and header clocks STOP (plus a frozen-clock screenshot). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../apps/rd-console/src/ContextHeader.svelte | 15 ++- .../src/screens/LiveRaceControl.svelte | 2 +- .../rd-console/tests/ContextHeader.test.ts | 63 +++++++++++ .../rd-console/tests/raceClock.svelte.test.ts | 100 ++++++++++++++++++ frontend/e2e/race.spec.ts | 34 ++++++ .../race-clock-frozen-unofficial.png | Bin 0 -> 167768 bytes 6 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 frontend/apps/rd-console/tests/ContextHeader.test.ts create mode 100644 frontend/apps/rd-console/tests/raceClock.svelte.test.ts create mode 100644 frontend/e2e/screenshots/race-clock-frozen-unofficial.png diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index 8ba185d..f4743de 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -10,7 +10,9 @@ * Contents (maintainer-approved spec): * • Event name — prominent; clicking it goes to **Live control** (not the picker). * • Current heat + phase — the active heat id and a {@link StatusPill} phase pill - * (Slice 0 phase colors), plus a live {@link RaceClock} while the heat is Running. + * (Slice 0 phase colors), plus the shared {@link RaceClock}: it ticks while the heat is + * Running and stays visible *frozen* at the race-end time through Unofficial/Final, so the + * header clock matches the live screen's heat clock rather than vanishing when the race ends. * • Connection status — the existing connection pill. * • "← Switch event" — the only way back to the picker. * (No date/location hover — the maintainer explicitly dropped that.) @@ -49,9 +51,14 @@ const phase = $derived(heat ? (live?.phase ?? 'Scheduled') : undefined); // The shared race clock (#62): ticks while Running, freezes on Unofficial/Final, resets - // otherwise. Reads the live phase reactively so it tracks the heat from one place. + // otherwise. Reads the live phase reactively so it tracks the heat from one place — the + // SAME source the live screen drives, so the two never contradict. const clock = useRaceClock(() => phase); - const running = $derived(phase === 'Running'); + // Show the heat clock while it's meaningful: ticking during the race (Running) and frozen at + // the race-end value once the race has closed (Unofficial) and through finalize (Final). Before + // the race starts (Scheduled/Staged/Armed) the clock reads 0, so we keep it hidden to avoid a + // misleading "0:00.000" — it appears the moment the race goes live and persists, frozen, after. + const showClock = $derived(phase === 'Running' || phase === 'Unofficial' || phase === 'Final');
    +
    +

    + 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: practice, + qualifying, and the mains are all rounds on the event with a format + (Open Practice, Qualifying, Round Robin, Single/Double + Elimination, ZippyQ, Multi-Main), each round targeting one class. 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 diff --git a/docs/pipeline-mains.html b/docs/pipeline-mains.html index 355a203..e56995c 100644 --- a/docs/pipeline-mains.html +++ b/docs/pipeline-mains.html @@ -39,10 +39,23 @@

    Role

    Capabilities

    @@ -74,7 +81,7 @@ {#if phase} {/if} - {#if running} + {#if showClock} {/if} {:else} diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 39cdee3..a4f41b8 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -88,7 +88,7 @@ // ── Race clock (#62) ──────────────────────────────────────────────────────────────── // The phase-driven elapsed clock now lives in the shared `useRaceClock` helper so the // persistent ContextHeader (#85) and this screen drive the *same* clock from one place - // (ticks while Running, freezes on Finished/Final, resets otherwise). See raceClock.svelte.ts. + // (ticks while Running, freezes on Unofficial/Final, resets otherwise). See raceClock.svelte.ts. const clock = useRaceClock(() => phase); const elapsedMs = $derived(clock.elapsedMs); diff --git a/frontend/apps/rd-console/tests/ContextHeader.test.ts b/frontend/apps/rd-console/tests/ContextHeader.test.ts new file mode 100644 index 0000000..c5ddb14 --- /dev/null +++ b/frontend/apps/rd-console/tests/ContextHeader.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import type { LiveRaceState } from '@gridfpv/types'; +import ContextHeader from '../src/ContextHeader.svelte'; +import { makeTestSession } from './support.js'; + +/** + * The persistent context-bar clock (#85) must mirror the live screen's heat clock: it ticks + * while Running and then stays **visible, frozen** at the race-end time through Unofficial/Final, + * rather than vanishing the instant the race closes (which made the header contradict the live + * screen, where the frozen clock stays on view). Before the race it's hidden (the clock reads 0). + */ +const liveAt = (phase: LiveRaceState['phase']): LiveRaceState => + ({ current_heat: 'heat-1', phase }) as LiveRaceState; + +const clockEl = () => document.querySelector('.ctx-clock .gridfpv-race-clock'); + +describe('ContextHeader heat clock', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('ticks while Running, then stays frozen and visible through Unofficial and Final', async () => { + const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(ContextHeader, { session, ongolive: () => {}, onswitchevent: () => {} }); + + // The clock is on view while Running and advancing. + await tick(); + expect(clockEl()).not.toBeNull(); + vi.advanceTimersByTime(4000); + await tick(); + const atEnd = clockEl()?.textContent ?? ''; + expect(atEnd).not.toBe('0:00.000'); + + // Time limit fires → Unofficial: the clock must remain visible AND stop changing. + pushLive(liveAt('Unofficial')); + await tick(); + expect(clockEl()).not.toBeNull(); + const frozen = clockEl()?.textContent ?? ''; + expect(frozen).toBe(atEnd); + + vi.advanceTimersByTime(5000); + await tick(); + expect(clockEl()?.textContent).toBe(frozen); + + // Finalize → Final: still visible, still frozen. + pushLive(liveAt('Final')); + await tick(); + vi.advanceTimersByTime(5000); + await tick(); + expect(clockEl()).not.toBeNull(); + expect(clockEl()?.textContent).toBe(frozen); + }); + + it('hides the clock before the race (Scheduled) to avoid a misleading 0:00', async () => { + const { session } = makeTestSession({ live: liveAt('Scheduled') }); + render(ContextHeader, { session, ongolive: () => {}, onswitchevent: () => {} }); + await tick(); + expect(clockEl()).toBeNull(); + // The heat + phase pill still render so the bar shows context. + expect(screen.getByText('Scheduled')).not.toBeNull(); + }); +}); diff --git a/frontend/apps/rd-console/tests/raceClock.svelte.test.ts b/frontend/apps/rd-console/tests/raceClock.svelte.test.ts new file mode 100644 index 0000000..957c9ed --- /dev/null +++ b/frontend/apps/rd-console/tests/raceClock.svelte.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushSync } from 'svelte'; +import { useRaceClock } from '../src/lib/raceClock.svelte.js'; + +/** + * Unit tests for the shared phase-driven race clock (#62, #85). Pins the freeze regression + * the Finished→Unofficial rename introduced: the clock must FREEZE on `Unofficial` (the race + * has ended; only the result isn't finalized yet) and stay frozen through `Final`, ticking only + * while `Running` and resetting to 0 on a fresh/idle heat or a heat change. + * + * The rune helper owns an internal `$effect`, so it's driven inside an `$effect.root` with a + * reactive `$state` phase and `flushSync()` to settle the effect after each phase change. Fake + * timers advance the wall clock the helper reads. + */ +describe('useRaceClock', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + /** Run `fn` with a reactive phase setter inside an owned effect root; returns a cleanup. */ + function harness() { + let phase = $state('Scheduled'); + let clock!: ReturnType; + const cleanup = $effect.root(() => { + clock = useRaceClock(() => phase); + }); + flushSync(); + return { + get elapsedMs() { + return clock.elapsedMs; + }, + setPhase(p: string | undefined) { + phase = p; + flushSync(); + }, + cleanup + }; + } + + it('ticks up while Running', () => { + const h = harness(); + h.setPhase('Running'); + expect(h.elapsedMs).toBe(0); + vi.advanceTimersByTime(3000); + expect(h.elapsedMs).toBeGreaterThanOrEqual(2900); + h.cleanup(); + }); + + it('freezes at the race-end value on Unofficial and does not keep counting', () => { + const h = harness(); + h.setPhase('Running'); + vi.advanceTimersByTime(5000); + const atEnd = h.elapsedMs; + expect(atEnd).toBeGreaterThanOrEqual(4900); + + // The time limit fires → Running transitions to Unofficial. The clock must STOP. + h.setPhase('Unofficial'); + const frozen = h.elapsedMs; + expect(frozen).toBe(atEnd); + + // Wall clock keeps moving, but the frozen reading must not advance (the bug: it free-ran). + vi.advanceTimersByTime(10_000); + expect(h.elapsedMs).toBe(frozen); + h.cleanup(); + }); + + it('stays frozen through Final (finalize does not restart the clock)', () => { + const h = harness(); + h.setPhase('Running'); + vi.advanceTimersByTime(4000); + h.setPhase('Unofficial'); + const frozen = h.elapsedMs; + + h.setPhase('Final'); + vi.advanceTimersByTime(5000); + expect(h.elapsedMs).toBe(frozen); + h.cleanup(); + }); + + it('resets to 0 on a new/idle heat (Scheduled) after a frozen race', () => { + const h = harness(); + h.setPhase('Running'); + vi.advanceTimersByTime(4000); + h.setPhase('Unofficial'); + expect(h.elapsedMs).toBeGreaterThan(0); + + // A fresh heat is Scheduled again → reset. + h.setPhase('Scheduled'); + expect(h.elapsedMs).toBe(0); + h.cleanup(); + }); + + it('resets to 0 when the heat goes idle (no heat / undefined phase)', () => { + const h = harness(); + h.setPhase('Running'); + vi.advanceTimersByTime(2000); + h.setPhase(undefined); + expect(h.elapsedMs).toBe(0); + h.cleanup(); + }); +}); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index f9e5449..2391cea 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -127,10 +127,44 @@ test('RD drives a full basic sim race through the console UI', async ({ page, di await page.getByRole('button', { name: 'ForceEnd', exact: true }).click(); await expect(page.locator('.phase').first()).toHaveText('Unofficial'); + // ── The race clock must FREEZE at Unofficial (the rename-regression fix) ─────────────── + // The race has ended (only the result isn't finalized yet), so the heat clock stops at the + // race-end instant instead of free-running. Read the HUD clock right after the transition, wait, + // and assert it has NOT advanced. The persistent header clock (#85) mirrors the same source. + const hudClock = page.locator('.hud-clock .gridfpv-race-clock'); + const headerClock = page.locator('.ctx-clock .gridfpv-race-clock'); + await expect(hudClock).toBeVisible(); + // The header clock stays VISIBLE on end too — it no longer vanishes the instant the race closes + // (the bug fix #2). Both are independent `useRaceClock` instances, so each must FREEZE on its own. + await expect(headerClock).toBeVisible(); + const frozenHud = await hudClock.textContent(); + const frozenHeader = await headerClock.textContent(); + // Both read a near-identical race-end time (they share the phase-driven freeze logic) — within a + // few ms, since each captured its Running start independently. + const toMs = (t: string | null) => { + const m = /(\d+):(\d+)\.(\d+)/.exec(t ?? ''); + return m ? (+m[1] * 60 + +m[2]) * 1000 + +m[3] : NaN; + }; + expect(Math.abs(toMs(frozenHud) - toMs(frozenHeader))).toBeLessThan(100); + await page.waitForTimeout(1500); + // The bug: the clock free-ran. The fix: each clock STOPS at its race-end value and does not tick. + expect(await hudClock.textContent()).toBe(frozenHud); + expect(await headerClock.textContent()).toBe(frozenHeader); + + // Screenshot the frozen clock in Unofficial for the PR. + const shots = process.env.GRIDFPV_SHOTS; + if (shots) { + await page.screenshot({ path: `${shots}/race-clock-frozen-unofficial.png`, fullPage: true }); + } + // ── Finalize the heat ──────────────────────────────────────────────────────────────── await page.getByRole('button', { name: 'Finalize', exact: true }).click(); await expect(page.locator('.phase').first()).toHaveText('Final'); + // The clock stays frozen through Final (finalize does not restart it). + await page.waitForTimeout(1000); + expect(await hudClock.textContent()).toBe(frozenHud); + // ── Revert re-opens the finalized heat, then Finalize locks it back in ──────────────── await page.getByRole('button', { name: 'Revert', exact: true }).click(); await page.getByRole('button', { name: 'Confirm' }).click(); diff --git a/frontend/e2e/screenshots/race-clock-frozen-unofficial.png b/frontend/e2e/screenshots/race-clock-frozen-unofficial.png new file mode 100644 index 0000000000000000000000000000000000000000..b94158fb0316a08be3a1b955b59182582bff7249 GIT binary patch literal 167768 zcmY(qbx>Ph*e#q8+#QO$6evN97k4f0!JR^IcXx*Zh2q7%IKiDlai?gD7cCO__`UbO zcjo)&Br|9B*=L^f=vr&XsH@6hp_8El001lndFhV;0MhGU5EUxI>(5lIm@5E)3s8`j z(DKeZF-GyH9GWf2u)MKW$Xv%b>n7g~#o#9eLll2g;-iMUr(Q%bl;9z-A|lu|t4s1g zV8MOENV@B}M)eI=O0A6g-AmuMy1l-CZ~E?ccP1*@zPY`1vDCxX+0@j;BmCWq>)V_B zKKi6-RK6DHZwN7lwea`0R(YKGva~;}z%Kcp$cIn#6M$)%CvgyscK3 zg4=s8dr-#Tu0r@o_Snt7ed);K;Kn)ZaY||Lb|ivg8@DpcPuDU|y0+mb#uka0YH6ka zI2;gw*=Z5}%|c-yfbqr#s|^e$DWvy0KjRnNvi$=T0TA{zRx{uu>Zj`f{@UU}SrtqQ znF=-Sv)-H+_Tn$|Bt!MiRUo}8blhE`$QoI_PVvH6-+Z|FO+nR?+>1mVPk-d^o7)F9 zi)B-_c){=EbKArY77(|$;(2O(OtPP?_l=~qJPl)zl$K115*QIbF?gE4+OvQKd0!$2O9`Xu|a^A!RlX`0j= z2-Z6ZqxTH5{9Qs;+Q%h|r|qCAvrsl+4YjQk>ZGx{VFhO;z=K^!DFGGW>xb}_a@x1a znxXz@U4vl2-@ZS*9j~fxwKbsWweyivpWqjvwV5QRUkB&3f$4} zVP#S^Ea9F-(@s@Aw~puiLvdx2sAsgt0JBGT5$rWk*y$jjMK{LDcjpYKE)cXCf6F69 z;?~<~Nl%V+HRi3*i)l5CHDqdt7zh82JmhD9-ZtRzbTU}MZOzg1!eS&2tubK@ z8qSgE_&l%0G-9i14jy2h1bHurkQ9`+lv9fJuv78sPwP^UK9PZSdKtb^vnFYReYQH@bbvDMAEW;9xjYG`SGUw6UgJ@d!kuyz5nHU zN!)U|MK1vJgq}i^i7he!IC)0i2AF^ qaa}E#wMv5&Hli*Qdi{YZycXVgt2o7nl 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

    1I^(u0YHwTt)_9^f zH#oO{uG6RJC$6siK^Pfj-L!vQW2t2|*$NWiZtSh_e`6nh0!1o{k&4)K}ZFpwsVDrp^FtZd(;5WY;~ zAUC>{3~H}V5-@E8e}^>*8%iLDi)DjkZH9s)bp~)s;^_{#v&mNb6DaN9eg`qTlBH6& zcMmI#+@-?@>W}u&E5YzM4^jR`Fm@C>w{faKp(Ds=*5ahrHgsI}37N%ybC+{cA#?B3 zoMeQoY+vMw1}P?xt#5zHtht zzaecWy#)(rXUK4Yn_^TJ3X}aS_(vQ)Cgm0}rYP}&t#r4!yD`J?mFbkY$n-Pth<)>l zuef1T_D|+*iK^u=dbNtC`{#;Lt=-{K!TDk!J!q}N^72H?dFBi|B^`ZxKfg@VY2=-~ zs#vy`nrS<%iO%|Ts>u+q(TdL+-WoB=RcURo(cFq{E9jI=I_%kA*3W!0E8k&1?-vcDv2l%lvRQ*K zoX4dfcVlGr{C*S@iej_*3UVF`emQ0;-QHf>Aq&ED`4DC`>N0k> zi^2g9q+5Xdq;9-ZbI^+V@l}IwTa8T))`=r{JNr~%xpIUJ6K30tpo_tnWTd8Vx{Bgx z(l$0J<>dofi2j{#E^LhjCSKNzMm5%|4^ju?1&!;Y{9XZ>K&OTLA=6QqX)O)O0->an zNQ8-IfYwA}$QdnFx87)gn{&fbtGVY#E$Hg2ItSw*k&T+hYVVQa8!;u`N%JkSrqF_l zRc(RtSZh}<6Yz0in^8RCckoR4k6*=xav+`4I^X49lOA9vxIKuHvS+2#{9Ic(b8O7n z!5e6^R45DDU+LxN0Uo8$IDyph4%^VV&K#~Wa`fc+0n)f6Zn`Z439svR$9?==rSLN5 zp|OsbgY%ljNP}XG`0T^r%Fts_I&-jOJ@7&0ps1-tBYP6i3E+KU>;kRjfsa$T<^ zVW-f_!DdRpK<0h!=3s=Nm!L7y4%m`rg`TIFe%7?YZZP$}NzN=Nk zsRe`nW`kB|ODD_J^lzdezL!|bkEPS@gOQu1R`&olPw-5+z6>tUpg^?NuRg)$|IoF)~KEo(odH4J(@iyhBbDX-CO zDrh}4(7la_h%Yf+-)ol-nfp9w`37I*WWyY>; z!zAfi?x9VPdx&ea%YK=HxTCXmi)u@eLHSp0C!a&8f@eqU5is$4m=}GiZnH7*TYF94 zaI#=V(GAR;#DGDyrK*!-xt-oHW6)5LIEowMX?+qlW-|#`j;<|NsnVaOcLJ6e-1Bi#`yCSf3ahOgF^poM z0jnzN0H<+HCK`SquT`Fj4$DgwgvO-Gr&U&v9m_kC4(sz^E@et`@R5+gTEoXk!nvdA zT}YA|1uS!Ry=m*keR^!k@FSQ-XO&%)&Vc!l;=|tDdNc-@fM%ZeCRrNt{GFPNUX#veRPJxX)v(7WqvucC*vzC-BOFXBDk#gE+1h-Zk^Xe0TzL?uieZ?i%r}ySeLJgW@4-MPm zD9$wa+8NNu2|0f$>anLnPk+}~DCg5@Jrb6B;}5TEr5;HGp91`Big>NH9Y*|8qYZtv zE%Q<8rqhUU{niS=y)MG+zP1WsO z5~92I3hk~qdJR!V#0K3YdGqS|d(dLjXFW1uN;fxNIzbb;nN)EIifBHlF-HR&*7y32 zVNP1!ADx_nfd}(WHVZ*BaRMrtm(O&6+`N7SnzG54(enf~FbpH+Wo_CJa@Eg4+|l9_ z76rRu44?bRYfesgHp$xMz3ba4wFNTcR!b!yeJ3e>AgLuC)w<}SjN>?d8(dG@*>}{3 zJD-WM4(?YH*y5d{|Kx>vKt*)mT=A+J<2_t{+{(}M1qHt@Fn?1n8FRGpK?fg+ckv7| zuOFKKcnnC>6neMO^MgTgol{v%p-x7@r;k9;lp^SN028+=msMi=H*{Ev!Mw#ka)k;i z>=ugJ^BCGGCo(||#MJH{36sjJ#l(+LPrBsGFE^GB7v7{j@Vu%AlR!h2jo-|7yH`vG z9tMh?J*q_r%M44Jnn4QG;5t7ix_n&y+qSSii!~#+kjunuS(5|~U|Rcgfga^8ZVXT2 zGBu}<8Xj~4)ug`MINV`VvHZZZ4q7H!F}W#_X2igK5SU8yAe*d*vS(GJDW1lz*wHFF zRywfn9%Ul4#LWuw&}C4YB(g&;tMDnff3HvzD-+`+SXHrs7ry0)@#5MHq zIc#|DZ&Fp0B8ERNGYltG!hMI6#>gcJ`U$zDyo9oLnkN~1Q`b#Q5Dgl2urqgibjg)! z;VaV8pmx>y4D@zh378VyctP7tE^KGLlhN@{HJ+H`8ty-y$%QRKel>WorU z(2yb97u1F2@+IZDR8NeVO%NP?%PV00^dMamn+vRcjHW_%Ny!Qz|q?qT`q6yf1qa3w53L(AqPJ*ai0rqc~g{?+>QN z%`aNIfoSMV6(~KS*jt6+uWL#NJeI}>@w0FC7gZ5DvpHw<%zTkb0Bi?7V~q*_%7wT*ch z2fZ(XT7hQv-z}B~y z+E}XUnRYK#?0cokmUC-79kIaxuQIFG;k2CCeMOefkLji;H;L3bQEi-5v!t|7^Pp-}L(~R$#DW90Y zjuI4#GWd2!+E0A2jWcaqH?d{mmnox}=jvUbCBO-vT8QXj9_^fFhT%H52EgjiAM0N| zr~coO)lK{|1tJ#MR>>%r)k00KHu(r09f3q3@2ozmW3_o;e9V+j+GTK6Zj2as?ym9s zZDJZX6MDg}+dPXBLq&#neZNwpsdQ+m<>8h`TqGUf59JznYF3^62ZobX|@?%$Gy7R8gIIrALH*r&Htla7o zX}as^h_~&g0hiBm!c?7ddI<}Nd?vufaQ+&u=egHwC+*<$0Bi}v91KX|UKGEMICH&% zfk++pPaTMV8_~{q5`@{r4X7w4mjCq^(fgT5UhLf|4$qk6{*%y-G8dtKzXB&S&o<;`|4yH))XS}X?`qcrQA zQxSGc{6U9C*hM8rSK4(MC}A`<7C#q@=S`e3@L{_ZQeX>YV4n6pri~=`Y-v3luFr9x z2U#HQMY#Ans>t5rS)z}yd}>`@zgRikzp@09zP*+6Xdg8%HT$jGdGpJ=;(;f%tb_DI zYJ~{tr$F`dfQG>@G9TEppDVnypI&%ysHs#X?lHqVtoQzHqinyiTkhi(OL8~!>K_B+ zrb^{8gX0C#3 za)fMw@iBEp{Cg{Ml_C(^B@iQs*BeDf%vi>j!zGLTR49bYGui;zO$8Ur9Nxk>oXL9l zWS(Teck-pJYv0V@N*G4mAW&Cd(F-o9uMsxvjv|%#5kl5hx*&GGahZpL##2-j_j(O& z)SzgE@|QPI(Yz{uwWuL$c#@&;5?x)!n-SsoF6jz>HcwCom(<2dhg%doc0)|3tHe|l z{5^MK#8d_k_&7f<9_67Po^CTVnh35NdGHor7zZ@9e`7MgSC^1*sm5hlOWbA*`%cyD zVY;u+J~pLM!N5j9us0p)5Y|}FdlPCy+t0Tza)wx`@^PZUF`H6UZO-$Ju4?Q&uO>zJQAew5gj_melwJmpuZQD^py0p56@-Q*e2AG}rSSiI-zYFMTmVJ-7svvatC z_;VlSqz(MZHq-s(OO5F}RoCsUHSQ>la`c)!c&J(k&3FTX#>l>wYdx zgNxcJ5tt9$Do^Ow+$fx?M%J5ZK@pbyt+Ag9*4LD0aPcO!@G6(xV`ZQFs^hiGMZ0FX zxSy122)5z7_HI4hmqV~Dp5+;xoY0s_$IcoD&U3h^mT(W9pnD*x9CCi%a7Kzc#{BJf z{BNvm)%U8ZI-x{`>x5KQ=BY*t9kgp-;}`)qo>a!$^Q%-h%^-2Y&1EF!6~Xr5ZBI`+ zl96xwd*x3UY8|254S7Hgcsz({4hd@g?#`7#X*KLUu$7=Z2dk-wn=2+lQadidd zabf;xP6if^v4Zh-;K(+Oo(i8H0lAy#sct2&_c4R%smJI86l}A!jZgLWIBvoba!iKn zC{CnTda@%dIQA2u@%`*~*E4((zCNnL_wHJUDLSfL?&RI!aP_8+PNJp6(}v-o*-2h- zh3Gvr>TIno9a{X1UdOA=KV#+PhSvAOtzXK5tHvMC0%BXc@z`k#F6q^QF6NW{fo#Do z?Rm{*$`w!T%%ZJilQO2UXm@7t_7jLb`f-a@-mq4HJ$1Lvbxxl59||#9FUDD$u<&uy zPj&`=l#L~uxT^QKD9@`M8m-h!H4`G%qavIQIacJ<*gT{cH_F`60z-k#cOKJWw4PTz zER^h$a$yTIF#&0xbT@Wp%rI@olwCIZb=9thW41!Dby(cPKpcQ_TS!^Xlc%Ih>Ui3I zHhvuZK;USCc+q;mIbl}7Vc}YVk6g`QTYo%=UTU84JtpE1pYH~$U1pSYMhaCTIeqGy z&Sz-gWzrKhrq&0x7JGRek8Ants??V9xzG=!$AK(yZfXb%9se?2^aoiz159!! zc~#3edq_%B$%FvpE8$Uj}R-W+z(d3YS;5Ttl)p6#Jd;sv!d!w%Bb1dVmt-vaAGjjBRC!~oQ=wYI# z@v~J2zo)bJf{vTBUb!J@psLYPntCGYl^Qhd+k-A#j-gzCXYo^tXq?Ob#6Mmn0=EQckt5D{Skv%8Mh!Lt0pl&n*$+6 zhV@~?wDyLRRd?b>{z7Ldlm!vL6N`IJ4iC33DX8jceL&M+ie}nMyU9>c7}8fjBL;fB ze_0szxseq6>?a+SS~^2b&qFw~db#u>H}Tqe(a`|Kf$dmdu3uT}n~?+2sF9 ze1VY%@w~7t!paP>m{V~cT2F`MY+je)k)=&O}>QqlGt5$lBT#}=`)S<>L z!TR=WK)r@C>sjlcXF@`F?2vUW*&WQ#sMnY&MXHbUn|tJM5Ro^%Z9!GA==>1dTEV^M z%`nyY<_qvn{?ID#ftPJ}^1`mj!6RSFx7bG2rvG%!!4w{zjd~IWF^zNd73q#5iezNM&yLv zecR?yRI@p}Mw!Ir$XM@C!&HlC-LwL^G!*mVyW^b74;yn@aNFqaZl1O6do#{v64yvN zbWEFVJnIb0M6}-~I@8G~yKvk?+w6aOKc0E?zr)*&B5p)nbrgHDHx`mTQC`t_>T94^ z_BH0UX6PDY=(y)=79XP%9GGq$daBZN=1zp(c?3$1kLj(w5$x&ic7@NzVME`rGOgbr zV>~|A=#5e>YkaUVr{YMwIrj%BN=&k3EGDsh^C3tu8;gEx9rS-F&v)x_c=pYn@Nl)Q z2$_2E7v^Y4FRkLIj=cuxxiDWJj{-7m7eg-R*za_WyhM zrOPe8PcTzG@L7F(`y+;AJtumV>y7@VIY?(4w;unR8d}WL!~4(0zT{u*)=#_s7s$p& z$9^VlCwp7ly+iy9vy|IU)1s0Yl9eea9yiSO6{y~TpT#>|nR=J{7arPiL)HK4JsrNC zP0Fh*0Pnq5Iuq|HP9~d}FdNhEi$9-9G(d|UXsS~0APrshk`;E|H6@9#baRNjS94iHS^;6OwBLeVc>qA$8xiC z&oxn5SnOE4e3+AL?T^sXXCtIDnl;G3Sm-;X7hP+{#!Rhk<`kB-Oh^l}^A44k+)BxR z&i-q%Cq0+vZ+~eEY`P>%Ts>PUJ|^Cgo!BQ`LFol=*nb_r$l?J;+7s)1dP79&UeqPN z$Nt}8S>I}NInPa=_8ZJ;il%l92~L!ls~sO%(cil0>#f)k1IMQ@TY?88ofT-=M}gI) zqyU$YIF8wtRt$Ld5=Zn|o_V$pTcBj^Gkl_godP{sYR(NB_g?O=R4B3DUcYul`g4n4 zx)Cu6-;a*fB%R5jxtLAYngjA{7v29uAeCYL{WblXf{ZcKt(S>kp`GVHk5!BG1+v97 zrW-9k7+6emz+$$Rt!f?p4)YSVfg{q!;K+;SBpMe8*F_)v+Zlq1Uz;OL3we4qdgO^9 zucq<#$4MmAcYRDa!p3LV{@53nUUas+V8JJ8n4G*@dRJNJ&vJkN&MWztN0ES?>C|~$ z=$})!;>pGp8xUgNo3gkBa*XFy4wtL-GE3~~YVFyOn@?O3=4+h2L#>H!SPT%sIY%)W z#AVj9iQCwKI)mhe-+Io^kEtx>$=;mZnN=>Slmg%~4(*j?Gv+fO+k0GR%(vvdH37RF zQ3Ta7Lp{TTT{5?%<&|DVwN1+4Gd%-baxD-B%A49U(XHI+s7!_l3p0t$$a1Mcj!RKQ z2l<)zR*JbAoICsweBNIZg?S+1+fX15#sO;GXh1uH0(wXOk_qTXUZ-yjOiB_iq+|N@ zf~_#rhqKO;Mwe6;z#GW(sIfQUWb>ElEqjcLhJQweFXAwqeri6uQ}7#jjLG3t-XEIn)dd^ z1mC{6@RD9^pZqibN0L(lh|}reWTcEQA4m8y!vkO(%DNbN_}a1WZ4&IU@X`!{Mxejz z>>wMDW-+Kk!Zu~g8;nbhpN4yapU;J?Xz}-A6TM|#pj-9HJ~)AcIyW{xESeQM)DMr& z4+z>M&y=I*aB*%rCm&Jwy}JO#_JFgF{_ZbS;YKf&qreQ3LIO%^1bm7Xx~{pQ$qc|Vh@0{%vvubj432$Mj#1O7rQZ)H>pyoaR8@pk60f^sAhmG z;6Oc9v4{;M(hl!} z%5+X2gBhrw>>5uGA4EMKgV#}7ZY6&_LP)oABW%swTTMWny11`tv1Fk?ZC|HyP$YA{hF+bPmE&v_bC5k$xiZZrwN96$8n!u(G3C|@}(P^I~cApc_6pq zAqA*+ncKPx3m+cIaI!ToW?n6ZtI9m4-*o^^6D+FBPWtxHcH|dL8ZrM)RlP zrrIf`0$Kzy(~RM-nyMUjiKu2-?U}D9Q!1VXyUSHGdQmCzNR_iQWM+x+=^wW}XVzq< z&GpmYX^(4`GL1#%S)xvC&$H-L40H`pM~gL?4`f=+QN&4jwv}kmJ%lA7tL#k(&CS(T z!VuT(Zmv!8sZ#(8;1K0UZg5O42RC&xOR;w|BKp+fow>wYW} z-~ivcpz$_W{L7mPhG`Dp#d72!1Ml#NNbN?8^6O_2{*^zuiN|8xEmeXsWb zk!MZG$;l#P3D%aP$3AyZk40Z}j5=Rom>Jxm60oWE9SMMmZ%F2GMMF-M&xc}u>zNG?s9=l7@-^y;706AiNTehO!7o)s*cL(b#aeJ`QFj{R3verV`t@xMG*+e%joxOD%Z zZi+xs_N9Aw7hHZ>*DA2zy6;ak-}w1|nds<0B^pFMTiHBW;x`=*4x7eC)684ll*9rY ze*BvS;!NUBnmxza~wtP!i=oSe5zVSE~B!iv=MOQre49lGeX( zi@sRF6kCqj5$}#v1I2%Z=5q}UMqB(tYoTB}5&4U!C@Dv@Ex00vhyU?tUpFVLK&8`~ zGF0!=UlcFBJzan6EN5iKUJT)7Xps1VIuLV?8%a2L0FWc__~jXy5%i0-1xq-W#7BTEdYN@^-3LYMtyx4NKd2SDccB`&Ovh|5u3pUT>y6vObqGPYx%x?dYtTEe`$$uh5+yX6n3drGeZfWOW@miPW zdB$sGIZx{3=q}nnOJk+9cN9xSIaw_15IsS%%%#L&YPpI!6rVeJuD&J;g$g>Ub5qH> z&&ql1l8KrG)9YEvi-upXqzWmR>$>n_54|cYWs~Pt;BiE(!ac7qlh;UM!y_Clh6Jxa zT6W8cacRa-2E7Oc|I^im1dIb?JREQ5Tb;gjm?ndN&jm7oE%ND`i2IEv;E|I?<)!qpP8sV?{c_L;JA?AduVM=+d5DRmG$oHzkcE?uGjRWuej8`O1peR z=n^;PRg$#PPp$I5rLPS{Zhy?rQ?7`$#ac{q5tP*223`l^SC9+)vk^dDDao=-hwW{I?`0N(DA$ zDJ^hGrkm*s2@gyO1voh@>HzPI?nOF#jk(AN&eCxP2U}LnOqcq`Y6U3=Ry?C zonLB@6Vym24%xItmU0h#%>Dsn3|jX7`*hz|YU;vb%W6M6EPsQE5AUdQs@_!4pqvbfGRxW;lJ0FRz8j4SvrN8A@4;*d)l z=in1r$z#9c`2m#MJb;>oc!HlbPzvhKjLx=}1NT+LeHSgx_4K9ofBUxqPPztw=-ECx z@Cs7ytlx81%Hp*d$L$Yx4s)J6gPCW(t8;$j+`#Pve)0LqS=W`8kOy;Kr=t1NPTt#L z-QnS@wGE>fT)7@hidT4l*|8iSnV-6 z-BK&LZNd_2I+y2(VuG5j)Fy zZyzzXK}RY^7jYK8k)r=k0Xc}w-+KV{Cn?=_r2fs(kmCqNj!MsY|JwNjv6|evA9c03 zpEo8<_7`aUKIO&WiR@$LY9BQ%jUzhi!SO^6^7J(G?-^$r``7V{Vhq4#XATKi14<(IOUq$^bZP`p0Rll9hP}?2 zX0A5y10#KkZQ)_wM?;-(IH(FRqd%i!Fg7*jF~f`@G!y0;T^3Gav$Oa1^fJ#e=XR1K z$K@g7e_b}smP7)x>B#{WD~msXO_+ejV%sCY=QEk}ewb5)C&r3rwmpI#@7u&L=%nI0 z@9kFnr@H_^SRNT0t8F^vynln7`avS^;bA25Xbkhm2Ut^ES=m~}X)uXGokluCOwQa! z+Rk-}u9ZA`lhFSt$lrY`J%74D)a<)`+zl&5yi))te01#m?(Z!2Z0^dcEnL&-VOwVu z$^|7VazBb0(6kQi6GzqUYGxk&T23-z3VX%-xZAA~205JpY)`?WOPa3iE=R1Fd!j&#e+suJc{(E_RUqe`0Fyba{+{&q z6yluHci-)qo}R|k<5&hx2;L)h zrH)(94N!}g25$#utl;f18SGIUB*Mt?@9AZ#akIg@{ff{dOJe*X@rglhvvq9?Ez9## z-MXUU(xAV)-Lz_%mR5^pN@Y0D?$72J_e9APqXz2><@{SrjP&u(ar*xwE&YGSENXd9 zB1Eg|DfRdGZRQ9hs+H{v|9@dUKB*@y=RFD5{QkF)Xtw+>CDs4WJpKQ3850jfo9MP> zpahdxYTNeLvs=wxVS#OE6OsGWA(Kdw8}!FQX?NF}g{C64S+0J{YSpE2skz3WH`n|O zA~Z-0Ivg0_Z_Dw5WU1Y{R@%s^fHzX*R94RzqDFIFm7!RQ&exJJB9H!xkwm=X+L8L4&%$C zW_m^*9A-Z#M@7O;PQ)Lg3|>=T+)<%Z z%vpmZm)@FYnKo}%aKY~qCm?Y#fmoaw$vw#vs@udmuzH=F03gjBeQpWUTCTTp-)Y*e zgiuq(8SVY|p(dH5E<}Il-ez`54(I>5n}!x*7_67Qhm$OLk>XbnER`%Ywd2c9LS(B} zPxg)UgABmUX71zG>Y7dM@KV{uEo2^4Gu{#OdkKvkVsXs3!QN@+ZegV&X}d61%)RG9 z^ZQFIr_ahv{Q3?-*ll*j$Q?BOm4c`FVQ1fUCj=V6Y{$*Ohd1+i(C2ej(l#}QcVC*> zZP4K+h8oM&m1`?oZxja>CuxjPJv4%s*#3#bT_f-OBW@~yK*y4ABh?77($dp|^^s_q ze`MGB9%wDn@ z%A;JLr7&A~`?x3X?5o!vm26E!4Ra1OCNBMS{`@q#M-Y+g((&b>sw%EPwefJA2nk#6 zU+Rp$Lv{6Y#Ubm-RB;e*;xz`RCpWW$Rd@R|-Oo2iBQV*dX`)1{g)`A$4h+*ik3RPj zzx;dQmW+Ve^XvTFx4QS%K+z)ytSYiS`$2oYyR%BW=}1kwvh=oT|7ilZUm92^r>v~0 zy1KNo$hfuyc4Ng3@x=}o{n&9v6boYHJ_Y`(&omWDA( zWzch7mcWU=M@dF9Q!l)eFH6(uiN3T%9RV;LBofLO?qF88)qdIYlYQOg!G$)5aSv4W zgzv8M`5kp6RQui6^ZN5MOpieQ4@>Je`ufw`vcx54!B}|LEG}l3`>sdO8&;J4uzhV{ zFjM1L?uQ`hy?9;O~^anBYvOXH>~jDPPhA0+pPVE)z3QwruCQ`aavggnJBi|)65s%&kvT_ zUqHTUYgO3L&r9%}ZAfMr9D8dFIl=sPrQj}22cHb6V_2!+6Asa9j>wz)!hz@TY2fbV zED32TUbP>aU!9ImDG&M-g7%97F>^{wix}Mb(kk*OVP;FN0#Yl^yP18sxlHnnA78HK zw;0S=mT6?Xy^qIkN$<3=oa##dgqhV^E?DhED0)S z9ozr$1stnf1}Qk`az;HTIoUKhGmHX@1Ms87jlB8W|0N|of5TDsA<;V7qwt*lNFdA+ z=DNG5oRSD@Pz%UWTc((7ejsqI`94+a%JU;ins-JaHzP&cXq;@w9|hIR4>3g#2F>SO zm5~0c!=4RGV%pdPEEsK(>9gLxy0^FI)P&E6oNk)c3@*Ka_>Q!>t@N55_V=sO&6nxd zvvF}vU~tYpN0|v)2@d%lCqKiAy1R+J)L8W-8H3x$vbl$=I8?M$({8m6`;$U*)b#ZK zy;tPJw#U}f8G3Q0 zo`HjRxK9;z@j)>|=YE04l0^U%xd12RQ|m1{(`zjCGTkd8P(djrg;TYR7~{_`%4y}_ z(@k5igqoZtfjcQzViXs{=w_(OUGU}~&JH4M@|Ms2_?5lis7*_=&iV@Eojj%3lG{jF z0rEW?&ewk7$CZ8qX5Hu7QMNQZhI%B3Zy$ghV^f{YQL&FkC~SJ`(3Q%Q3mHA-bsO#; zqLhpeAn02_-GcIg&a*Ue&+SzJP7nBOb>HOZsfu!dpkm(!J`U>gUf;|3?%_cMKPo<< zqtfoho2WWp?E_f|?d6C3Y4$0v&r892{9vm-lKPkZ3 zr#^0Se|4ht%4M=k&gA!Wd)*6yUdRyV@r6NmYD}`0-XUt91AQZA9RGB$E%$d(Uq| z9Jgl)Gx<%ZETA6$gRQvG;@fZj7DsuXlls|^?0Jva#;aEf!f`9*NM@f6b66*D9VMQcsdB$mSi06D+-l8q4GPh z=hPiQ^UqA5l>h>e@|suYhUyQF5<7(uzn_VTZ zmqGh_*~4+o+q1p4?IdM_w+ljZ8FYT`H&Lx9e0Th%h9*yL8`ysfF=|P{AkqWhJa9`s zg`T7EOdq*@hfwjDOoNFpGWqONZT3I;RZmcg(S$Sw!kfom|nrk$%pO10e z=+h|qi zlET6FxuqUnDxLl2j0A$VS8oFLA=*& zma5c{X9HP?w_|%1P^n{iRWfe^Y}4!2>&;2wPs6|-uO_wSvcrSM6zt6b{brN=#Jl48Ka@wjyf2sbW&rZYPzz)oikcP*hTVP^rC`}WOA zbKQo1{)R``qBT8wHtbbjv#!{>a!a=3rDttjD$CqiZilUtg?QbUfjMROk967NH+=>E z7GzT2i_;UGYBrc^NBSM)8ILACoe^<}e(o{f5G3U$lFD}I?%$%T3&UX?CQVbrCyYg$ z*OYd~Lm1JPmfCfvmNqs;T42|$zs^Gri%f+L4d3&aHpSbbe(fZSRITv%F6V7{w*ZWE)_lU3ej1`YdPTo3AGnfqoOX8xU#69fSBjOwPt?c+8Sy*&U|V{#r+iSn-~d;*HInon7@- zOmbXe10?M9vQ`E_r)+s@kfF!pXyFv3KbmTWnchBn@MFBUP?+GMz?4Q+p~so9*QazB>vUVy zLVd{LkK-zR;?Oszw_5#Obh96vHNyF@3ksisrO|0d%BO&u*gu!z9So9u9JC(8v!u;5 z4$-ryu8250r%0Z6>*zLDgd#5{z?hRk*v@2y&3LgcJf3%Rd)s^Wf+{H~N%y|!{qI+M zBCWZ$<&O+pMuJcotYYQ22~F+pQbLFBuTh2JycDMiBQ@$hG8+#UUJ8MJ+`FFQ?c(;Z zaDs|rC%E072@RoV=GB|ZsVX>Mv*Voa83xk=z~{}LM}GU5J>=&e@f4MwQ8q>1=8eir ztRIdDi9;3^7DBEtMwBGP!lonb1lHEpbx1!sS65PHOWdT2?e40}p?n_3+WQ^PWOoqR&2Wdt;^)(qUR>u%y#!=0vg2U5L-#3k!Sts}m?y@Q!S-u!5FRG{eJ}Nops45-j zG%+{_#$A+^0*VZNepoI(*yQBoii$IjQMHwcNk-NuDP{ehq%O&t_CBY@E%PPg+EA2g zJd;^N!H^+~Z^igzb|={WUaKF)c1NwZlu}6x{x;Gf^**`qw;>dTgW&)(-lFARmWJgA zV_3@L`qU^@q>No(11mRmEoQ+je)*wpdm?5^+`U5GLhkjicic(30fHEd3?j_v)&niBWt@_)#$~Al3HM2iKKYT zD9s=D$?ay1oSxt?Yo0da52&aNA#*h-CRj=w`0|3!?{JyP$f@_uj1h8lW*gpnP#FLv zo#2BtlQK-w5-vg`Zh6wwsv2-2{n94KEtAD%u&E!T7#2V5+2i|Hb;yGF@tQFfn}^!m z9T{(6{brG1PF!fWxP8;W-X3gor*7_|&ihlLjm|fC*8H6Jshg{7<*LZM)1~AYa$Q^L&ZMInth;@kPl|1>% zyp+HD5O={o=3cq)uiV@zL;9czdk5nUS>Yo8e!2amhsCg+b&f}>MO8(}suR}2?RHl6 z9XdHq=WEsE0dpZ>I2cJCfx)^DokqJfCrN(fj;OO^9jju?w#i3;GwXbsr`;{E^wTbK z)wh-~#Tg2C^KJ76TllJCCz1L~OdiKah=tH;#bO?F4q-blN zgv@eMhwsGtfi^XZ&Uch;k=y|OGCo8FIdJGG)F+dJ9cR>+#ix)JWH-`(Kyk$uJIH}u zjgO-k)a%8|bovSzY$A{iWBVQLhEfM-`c`fMKPfMF5{JNTUQv&t&sT}MT7)#Cxti_X zAO9Aa`V^bRqO3L^Vlz{~t!*Hz*nDJ8dxsLN2-fQm#-?st`3Q;B88~e|gj^wvBbdO* zF`nEcI$GM@*7a5@<7l_VG;bU>lN@5(Gid1P=~;ZkTY?6aFqn?LqIyN7ax;*h?(0+< zB_rI_J z&xS`ormZRdZ_(6$*#Cc|hUII*KZ?`v4dH^VsUc9T>UCY#{}bGN&HOkYt_-3`K*Ys! z`(1;rr2lsm_4`*AGLAJP{>%#AzAs<@A8pKE2g9P|CoOuC$=C&xJMP`)h+rBHG5sKO zjRAWCSay^QAbNiFH4v|EW?ALvZrj%tl_AH5(e})*&V0wF34Z_(xRS0K0D=EpgTUHY zXo-_~5WcTN0o7?FSS;1;lRI4=rHBv9*Ub0exPKMoW^Qf2tB=eIHtF=?~ zyzYA1-rGM%$pG-(E%O?DZwCP54uC7XP<(LaCZ58ZFTgVpV_nyTA`{2^0hLb(0{xa3 z?Z;kRdIgIdykZKjBDglR8t{qvuW3?)d5%y1U`GJFS{;DL3$8OqltchX{=YB!|1M(s zzlAISZu@^%CP;Vhj&1;hf`kmL^6yXl1lNWD*!rxuJSP7A<^PPtC-ebir6+gG#rM1Z z#aR5+y7Q)NE0zE0(PtbBWZd}e2W+m_KiPx3j)eG_*)Pv7Xqf>}|M9Kk%SrM7>e88L zR!C@M02xEnPP!nOHhh!hf&Y1QAW#XodqDSqpC7cz zbN6l?hjjd&?ANDmGByQxEjeY#M`i~9<$nBFjxY@|mJUERwApgk>A}nXW z^<|N-hVuK@#%cXgiNybyRxI6lDa`*aK(gX9r<= z7CXnvub&H&?Ob%Z_g#{~^254*#LmSK5O;dydS&`6FUgF|o%Vyt^)=Vrr!vHjeW7G& zs^g1pOKBDmp?q8$TYv&KVGlaCtMHPNs#c97E;cowg#uIg8N%SY~vxb5rp zjJm^#io^CG^D0DGid^dIj$P}h28$gV5!q4vm&QP1%-4=AYPNq*{+Tz(z2-W}pwiSa z_~~~579B&B9rX<@!_}711!*6!LR+Xz{oz}{tZPnN0fn8fKL64fj9Fh0imjw zY#uyy@a^d-`@hy637Z0n(Kmkk!(iDW4>l|z3W}@8r9b)63@68Y*V9w8k~$%0Cg;oR zQ(+4nptzjFcdO_%{`I{HtvBJYzaH)>*~Zt5aPH}b;jO(`g^F_e-7O*7%c9AD15Akw z{|v%>QaSX+`atJ8f>dA6%Fp?XUtwLEuHEEB_TO84W1(hVxC!nHOzwa(LX zex28-|F}JxmBT>hAo{v*1y!K|3Z#LnM`=N?=h0nIAh`=5<)H5pHatGqX`ft+0hSAr zmtUxL8q~Nxbk@)OYdga(P-Xr-pY;!)O1R7oEevN#lobUecvO9GDItYYYuh~NX{`UVJumnn- z{2&7YQJ}U-8XVV-nI;j4TKqa2Lg6-o1xP{p9a(24V%PqQ48$+hZK+vz9Uc|sSHK5} z>aX1MaN$s*9>W6-##c~}#axpD1UXfs`JwnA>hCWtUS$iJ{=1R4+gg+9!S#%l7gN1o z!~EM0phvx%jg=Ebnn<#J=s1+OP`qxY`;RWomM+ z5q9p^47*@1Kv6s};9UkJW1foSoaOhstQUxK%m3~l5dE>mdHuca7kNfMSEziEO3<~c zlCSvV`8_Q{^yT1m&W^D6%_P&5gQ>?Z+0t zF|{t1JNbJ(-cn8^6wplO>jV31lw{}LH!@8G3ANSnK-6q6h4CtmR6ZBhxUaOGb8hv|x}_rwVq371LXF$Hc(5S5iGB)?AC`OA5fgKB1edG|{I9zMX1 zqN1iY2`m?J2wt_de!^dP1C-*MEmD{dD8Ea;I_OE6oOcUBBx?ipyMTp3RB`fAf0h;? z1^@Y3QRgQ9wNWV$`?4pC{}$NQZ<;R^E~Jh5S{VsJj+7NI>tS_G86Lu27-BN*-w0Ha{A&{1^7rHO$I3Y6NoFY5~O{~k!%#p^x| zT>jwt@}NT%mj=We1116>P08fObGmplAzJ2tFQIZxJ|lI|WWoog)VNZ9M=zuvAsOYE z08Cr_TXOBC$4ctKR=&fv66rMAVxYF0RR73mPioYBCxN+~Bmb7Fesr{B@h%?lzq_}C zX03Ol05a$GrJeW4f+ov<0BkcQP@nfZmxj@wT)?{YsC}_qLyTZL_NhGHzjqBG{bQ@c zIQUEI0U5r3>=c8FT4!AsKnf*%w&f^lee>G1O7ynUwUM@(ss#T-Yx|#U%m;q-_dzD= z@}KLwujeG^k6y=cU`jS7zo=R{KER;niRRB3fsZx9Zyr4U;(gpKcobv1i}WW=u@rKr&6TT(^H{VcS3I9N z9C8)t06Ck%ag|k_1m#cOL9rt`COR|7da z1!PqBYs&nd#qE4 zGE%$@wo7~Qc@hwb0bgaFfw3DmY(bVC=XQ0-V#yJJ-E+9)oE%9%dC{3iM8%yjWUktkmepU13OcD)f;(FZ_u$*Tol)Pe4oB+1QMC^lSB2$W-m zk_F0@1Hotj{skYmNdg?>q4lzYl0=R7C!4Ciwz8(baXy$C)c6Ti@%Gm4C)-bhgM&$i zMn=L#E+;Rz%9_{g0Nh4MsHVyiTIEVcFY$m(?$f7FN|9GJxQq7g?J_%uJyDO1TbEV8 zthxcl5Y}mZZu(C8`DBQ$H2i5|A`Na2gZsR(5G5xpndal|cNMtTyc4|rT212fp{A+n z?=C9Ca*LBf8xdqu-zl)$P>cx)iM#DeISe!aY`jH)YiOZp|E()ac>v&E`LbQ`!>j1K znfz8NB)!l03`El<@tX=tTLb0bsMgE0F zF0x`6x?`s5J_N+yl_@)LC#tYS@%+$|yAi>V{W*uPug-hTL86zyOim$~nlf^GFngGX zL;sbVs?Kg~TuTafAiwzqcaSCysEC$T-dKmB2bhxd2Yt4xjIHeLO^?0JASNk1=*yBsqCd z;;f&&X|C02)rk_sF_o5*LJvd~#Qm1_0Ycs$`JFFq5eHQXS7uBXkCM?Ojgmm|j-F*z ziC+8#Vm?u@2uFTVlXpijS58nQSRs(~@+we=Y&q4?P-Gs>D|}VSx?ihpjO$--<(_vr zYdOO$$l-eKk2tSuRxYABz*nX4RRhSPm=vh>vK8V3xXP_7OT@a^#JR>#t9K|tkSz)( ziy4aVuaO74-9h)vlrIyyXU;||y+bN)62Q73P4pP-3eNAM z34FDBW6$>t=Z67YO~+bb{Y}uBR0@iF_^>LHet7)}0Pc4knhy-MICh*&_?*iy{TUis z-c#4oOX_2(lDV`sI&3{_jlpd1?!Iu@_@mu=%HtO_po+OXnVA3hkkSkqKiCh4E4ND0 z(9j&F&tumnCc!)7LBv$R;oOnYd#QlWcM(=q`P{*0#uXs+U3fNdo1@!gB8N>nq;QV{ z$(EY|&~naqooVH+ra)Cr0`6$jGR@pvS-IuP(iGKUj=l0Mo~^P31Dwm%v`a>6K@hU( z3>4<4fIhdfFt;x~KB#zds(Y6#1c$Ek)>$p}-ov3RrF!-Jfqm#Hu!mUUCD#`l_+}cw z7QP(e{LYM(xKaQv@Zl)eRrFdsWxV8hA5^Mm>yn)QBaL<){36xCHx;w#GRa8*yE@sM znVI3MH{nsd)t@fnKG%4JJO$m^=+l9ZFWI~)0H)RlF{IPJ6qxGE_60`7j8O!eBE=}raA1k@f9me2jcIn)5P4_ zd+iDA!sUD)eBiSv2?Y$v$i$WXK@+RNc)D5vz90UFSZ49}kpAff#)*ygVMC9a)N38R z34p81*Sz0e<9){P6A~D8 zacl|e&wG1KxWq=>xbdt}&z81B{SrCsk^oN9Ur>l*qRue2Q-wgJ zu`%WXeg{SKy(cJJ2Xpf`PiJm`_)8?vlN3N~ti|ggaqv`nq-3kEQQF4@A<0)+rdKNk zh#N1hrBxJFV=Gbp{fAcu(|rn@a9B5l1=j2E|!FPE|i zj;550jdXshR&1A*TT{SUNgTZYha74vbylG2vvLUMjd-2)@ZsuEK-*x2X;Yc>(`3}BnWCydL5>B;nsbj6U8XOfN>*NKnj=2BM+Ex_Px zK20hDJlB(bv-FFy$f}I9bui|40b;DmatKI|;njW%xGwx|K)pE-1$>c3&cV0G+sNay zjUcUIt;;iUBYIHEbqI8{YG2h*>!bAm>vGe0=tpWCJk`C<-38D#a6?41g)v9{k&`+| zWDs|U#>FH3-~;Y+&fumv^`kX_w2nS;0A@nDj_>N>D(qq|-Ad=m4;LbJ*b5jUFE_VB z@C0C0!_ycy;m>zc3+!)$+I0SRK5XQ)!%n$DQN`0roEEpi*x2|hz%hE|G3-_KBi5MJQ&SO(rc#Y2wCvYeG?D(MaX!I{BTTz04mCI!3 zBU^DLSy?nv>Bi+#@qC8;Yh~Pzul89Y?}6H;0B+v3M<+Y4FYe|G9Dx9BgyTyLhf3Ns z`mlEX3J`sN$_QHE{vUp7_MSHE;>Pt!jN&%$7lMDjATGV-67(ah4+p~5LQxh7@lDpp zl3z4$GvHZ2eLBkhEkozsy$2PH$jwwsnZ3R5xA2`P-@Oc#C)$*}`_^mEH)dxQXThP1 zuOJ*G_~&f7vDsI=s!8Dw|NKRZ=Z#^ONB@&iAsy-eUw#<~B#DpD|L@75<6F1d{`>9! z-wWF~K*O~7Q8@+q{T2dvApg_uMMwFNii-C{Ab)Xke17Tejhd}wuhMlRGU(7<#|!*W z_%gsIw~~w*vEttd_mXT8p(gG8_~DXHq20;b$%fW`3=fn6pFPxS(FJ-?*BR|M;F7NN zDWBiDUoYeFq)=s;!$Vt^jpOA z|5ngdi)iNqN#{RH{2>%)?smtr@!mBaQxXC+CuTvMsiU4<+{Q=^lIF;!GkWj(V(wOz zz8O9UFgIYA5_5J7ib%M~mMegRi80Jw9eUuyAIsk~0Zhwq)5L!~lYl6(MWd!N1<0qO zDkyzg6(gR352)9a1i>HSRo@RW%Q7cIx#CF^E!;8xKxCXbo67fj;r5QG#N!5;#Bj0f z((a7XVNPP9)kJn*56&txEw-ff@^GLS; zn%I1OpL0{*In6H$ZmvQpKpVcA%QjKU%4*!uDa95yeZnC6cL1T!998%!_JuCZx(egS zwhg(?IZ=>o_pz&s+v0%-+nb;s@;x3-Ue*%#y%p;;qn2;0sN_%P<`$p&8W3`uAv7Wv zkIDJc1HIjH@(s+OrZeLYlhGbkYf^H!hfSM?Dh%Kh53FE7n(1z{=NZy_Z`pb6m!`;7 zsRC=X!$fH=#0<)-8=V#}>n$PKBBg)v!tU26&0bCgI@tW5(=#T1lf#wXR=?JU2G0;O z#>oaVj)2{lPvc4r>+B~=4U4o(8aE3d*liXZ2uHd-VK-M9w_|_5XowzAu3~5EJ7T{&vrRq=9y_Rm*TB7Pdt4f|cumd(?!{&r?Y?JHz_l6gI41;)t zRt&AuR>Cy5LDJ%j3iK%*9JDS%?{#~;-=bThUK;Agd8YUgZJY)8iRjf+&OIRhn)>Xu z#Ir@)z8d%)qMpYc-RFci5sU%Sj`M*6UtXaTesr(ce3xi_B=OW9)k!qvY-V6E7ZRCh z!Vxo4bAK>Rv!HM_G~Q1wyiZBB!RLTJ#F1qgeL_b)k2(~VLgo@%Zpih?D>Ew<7G&Ca zaSaZ!3@^`()$t90kN4l&+A3kNH;BlJFHZH{-6JQAAgMcvFp~fs<_k2;I9<}2otryF zy3W$UK{b#UvncyoR#%)3OTUSa^T7Ff2_z~}QSgp-^Cb&MjfaIsWb&EwL5a?G4PlVE z>AhM^tKD$M1s%qSd=HDHlUochJt+WndI z{Wl0YV(1{LJ_fGXvk^tU`&NGWdg)M|DWPs+QMVosY$-sm0C3D`x}q$v^Ul z^CRYP8)l`uX{~+%Q;F8RyNsRDdXY>>x#=4NMb(Y^>YDtv$2Ub(%PgRE2M2@cQnFP0 z2c-MtcQSjgP`xZS+nB|~8nc?4b8=XpKi`@#_NzGs)H)a>Jlcll9hPzw?04-~S67`H zBo5SXkdYyFWJ==naO4g_)B(-}?GR780>On936WnB(EYhqg_m%*@Oijh`tw zdwa*Rv9Ym5+GQk+&qetkeRJXbw`kBskiCULj1Q3hc)o#wUpDlAjU^#X)oy}zU7iog zx5}%%_qfW#;#Enx?ml$(+nztV?~VvzNuOtpf5Bvgsjhs|a8}OYO`c}=cl#6~B%^J( zbziU?*MWPYBJR4?!-i^<8lmvnOR`-@**xDUQhcrvQNpJz-X$euzsbl8we*Fi5JOl| z=j8={mh+Q%0${cHkBmkqqBDH%q$md5*LD`oM_QWD97=X3MajmrQ&kUNaLfGB6h`bl zLDP!i8EpGr_ur@FWq#_qv4d`v=@Q%Rr;6gVOd9!|%2AhOIrA||S7A9XTr`yG;&U{H z56hB6MC`0;1aDKwaZq`s$3@95v)Ne*Ze69&<1&ycgQbF?_4SX|HPhwa5|E}*Z-P88 zrgWkARjL$g6XjV?iA_FniiPJ*1=&F)@P`xj_rI;ItVAzGkC^pT()6Mq%!%H-d6Sj| z9lfL;(_8O8l%|#~S2kaN(1XL2@0^v~Y;4&`*x35CY8*mFN-tWj>T#YMMs*_QON60M zO-X5PZa%$gNET*hW`STg^U*`2detEza&B(Io9e(0FxM5^dv-ghJv8*Kr6mVGND?G) z5NGM)iX`}8?<%%*`>Ss-*w=IhY`;ZLEt);_&C`WL2)Bh7@NSJ#qHXFdode=i!n*U- zHSrjctSn~HSkU){u5VEJq{<4-aCMG5IBzeE{?Wd|B4~0J$~Z*0kPJ>99u%_5=(Son zJePWQW;lf*u%@Z%)zSDV5926Y%F$B(HkQfgYl}DYPD{(E$wx9##Fe|cGRp@ZIp2kp z{6?rkPVS&uW~46Ov<%tduzN!cvy~}Bu%&><+H3gV-PRjSJ#?I}vQ$-5TRgvHAO2Kc zgFYMXAV<*I+0IpVeSATHf*Jd|qW=~=J41@ESG+hlI8Zb)RLbxU`w{xKG2VsqSyZi= z*#~P7JHx3nY61psfjO;KhUE8ri^~3rpQ%y?P?{aO=D(R|oSfRt$U|R&2sfTEM{OV? z!SqtxM!Dkv*Cr$^43YEw%-r0ZoKdWDWYu!eZ|PNg*TcP*qsbJSn;CqRWp<4{Xb$%cfNpwqTUQ}43$k@;w z2CSemEQqsb@al@Nf<%o{@N;Xa>R+9tA%qGRMJL`>G_=9xjYS-Ob@;1EeL{;)Riko; zK3gPmb3lBmw)nu?H)M80+Ee#-%h~>6f#H{qgMEu>^W3guBi5*2V};p7XTxrMur{^j z{CZFHPkqNlRPPsqJQH;-^_($bW4v0-0f=^MV$a?S>^!b?YU*NJECgZ_bzGQ?VtN1m zeL+FN`09|3#AzqR*_?ZNdb+WN1$)45A+)S4rfYhyHJh9zi@8MAwR7m1SSU@~c_cab zBU5*0cbb@Mv|XA0rJ=pOijk3#si{6Yfvp@cLK#-mnTj*MLxp4C_azE2TA%j)Ugu-I z3))cVjPN6LWwU0BFt>?zHVskj|5|RUYMuu3dt$h?!FKnp)uzzd5^pUi0a~9-Xp$Ia zjM1jXwDZ|6FEOQmOrM#y1uL%6O795_*|WKFY;27^%z1{4GTZPyZLquFkZhT5YX3Vh zG+O_2DJLfF+XJaN4qzIu>I~ww?)K##nMPIEGaAXrMU)kkI(>Gg3S;XEy3kFuKAvH` zJ9$}(CsXxYU2|+K4cSjWC|Dm2R0`ILPER)Xrom<0bz7OCa}UFY5?`cS(}&)QEa%lV(A2~kyo-8wyc-JJnW-zQFXw;q)E_|^~%72+YSOG)5OQe{-Bo(Wki+7OwqUmpQ2wNkKGG^g<%yA zt-C@ONk7tQ{-~ho5-S9%S_kE4)#?2&EFi`n1RTs{ZW0kmp0{aE>s-zSuR64BFD|y) z^k_xhGE2r=5DWOL@IHF<+*yP{KtQ0kOR021jdVKcM+`WG#J8FI%&B9w!Ju{OW4YAI ztSDnG^4)q){Bd3A-;tT$MUj=PRe9W8zyd<+@5|g%%L}sM${GBfLnp9tG6T6Vp2w4! zlW1X$HhCX@*awu2QDrcP!K;VgQk2D1=}VhDi5%kM;T3~yQE!^~;r(XKIIT(r`}TMD z+{iG}H4sOhKIv<~NZ8yB*m*3d2ic+S8}7j_H?~``#Rd7ALSrFkLVg#BtS?vKTCCD^ zM`=m8abOO1Tcq(vc%p- zvvq_9wr67YG4 zAK&LS`apMZM)JaAzx|oo(e`3`E zQi5?Di^~8GofxPG*&7I&z4u@+Etg1rS2el{yG z`SH1-eEr4}3bnSze*d;zKYj~38RzWmdUAYx?C5Sbk(G+O+KA~0(f_WlFQf%~k7UOMhAZba1H%L}rz~F*#JXY4wKM9TIO^bX00u|F6 z%<9*_cM#yk^N%<Uh= z`=Xr))MJd^w0-dt@pzTkC>Rb{#08{z9V~AQp88e9S#PyY^G5W1eez33?mQ`4@-e&Q zzlP7L0OgAexaPX?^G|Uf@*m-Ul}`x_Z`?|1=K@^b3^M@zaEZ30#EqDRo^i86RN}inwegZpS~FCp|9{;BJe{rTrG}TexbdT!GrB(%;y=?u zI6haOF?CYNE0);`&{bbw+F^1Zpw{^FT8tK15ypG=^!lP{U4^_{8`LxkwBzn&N6co5 z`aCmUYKsxQ)k!9iX{cy@46go{(;Z0*BJ1L#3G3U>9-aZbOx2;zT=MKxk*~ADAQUZaVtD zD3ljL7r^T*6R|3Ik@3=)MQzg7Mj5dG9blgYAOYtW_4N}%WpX+yE#TtCxQ>cAvjK}E z(y|Yw6RijD0pNo8@ikZ^J@j5p-Rh!BxJWOXZ>Buhh-^Jr(TtQO(Bu{{)xC3>{W?2y)7=cHSEwe%{P_1 z;)&=#nt=STw=SR;Ue&Ur4u;1yA5GYCuH`=k)bM+x=*;81#iw!3Cd&>=R%S2l{V4po zUltACd2C_dLZYPo<{tHbwyaLl+y!KN7F5(6R*p`F;mdlj_@IA_NKWp^EeXEVt;bZYq*m>F$Qzqlgm@k&gu6<9#wJTj9 zhJP`i)jLSCgp*D>`>XoB-#GJ4sB>8$*S+phXJ_Dn8bSZy#A7RZl^yFgTUTh0`6rkl z#;eJZN)>uW`mk>0jgM9|+|U(Uk?Y>_3=+@j$hFttjZ9 z)~AOxFGJhBz)PU?OPt<2d=W~SH;>+U>d>#H)2&jKPvKn5H>i~i#8_Ma01LQfV`?i7 z!h8Y%hK?T2MySf3qR7+h`JMR)DC~mRLHH&w%>R z;x~#3e0H(BcaGLCcL;e3yuS7|6nH(uD%4{2la+14+5{`b`sg_CQjqO-LuoB2WF`w_T0HX)>;a1sMm6h}Urm}0yiXcOo zr*XWVcGl@qi()NOl00?@RWKVX>)m?H6xV|?ZX4V z7)5x^8YSvDVSt_e>2|+G3ZUyd{C%=F@peJe1AYpv24xh}_pi;yV7lYFb!G$lq;#e* zeW+i<39?=6ydfwsgN3EtEQ`f@Sx^0W3#6Qv1gL_M)atuE1f@{P<1n7BUFDD?BATY5 ze{eo9VVok-h&$}kfZgFXSBvwwHlKdbS7nf=* z!E9`~sL6^VXyg!-3mm~DmH?_wf4B$30Ty?F9gz(P3`E2Q?eg*SzbhI8+(Rf89~pUM z23QLWhS0$;FD?K`h>(DAbh=E3yY$zuTX*hcVOm>T2?z)d4m#4(z+gTRZC+?oedFZD ze;=%Uvc26w#zLU`)eVpfyJG5TciL$}`b8d<^*Qgr(t$?|dqj3P*llm_WSud$GAUl! zV$dT&N2M!8a{J!22abtJQtYx9YHTR{;rQ#)Qnao>9(=VDu3b9l<%3+xg^OVKDGV*J z8mjmGPphuNMb;!w{!RZ%IClrF1x*8VslGSa$FYb z?s`^Vv}bsFdZ)2DJDX((+Pg|yljm+FZ)=e9wj@tdC1OzczmE~ZpQxvMX_xPc2U>{u zjQguV7B6t%cVS*+R5o9Sdt|4YB%0Q-c=P;n#6yY6NKwUtD(^t2M9bTwam39dz5IA3 z?D(|*Du1IRV?i2!xb!L4Q#adrl_S-wau58vhNP`e6`Fw9kC^vbzhsj;V%cVFOh`Ch zd+b+^dJ{001{1JWZf=x@Mg^Lqel>5|YGO)_YPO~bh&Q4{r?ZPoD@W$v^Aih7_hdY- z9k{V^*B>wAwt&T8f)`kTJ$^Ua2XGMYTajBF4am6l?|e>WhKD8CH7iyXTVC1EqpNBV z9dmY9_0NDnFh6a$;EnmYvr-nh2!vl^|DcuN>e?#8%}chMh{>;=R~1p29m9gR0m2V> zBwBx#85nYFcJR*OAs|2qg=zzWIKCS@%f6@m*7|TN%F~^RNIN(RP)}F!Y-p%9)YREn zUhX)(M58xd&`a$f=rD!^*M5Rv?Vg~=AIUT2lm~YoJh`X1(Q(P4YJ1UG>w7Q-+f8mG zi4phM__OQ`a0u#sC4?M+WR#9gKcHE%@ETweTAM9~&pYJQts8qpx$s8GOI#tpm)fTZ zjrCDLc81`+)h*4*O%n@?*{GG!!|0_O5POY+Phq!xvp%qxp$`b!n^8YxyxsNkF*b!T zjb{#h&yruT3p^9T;EiMBP5F})pC(+;4jLAnTR409N6!7ivnPe-@O}93s*H`W4$F0C zAAscjr@C^iYb(`z-3W;YMl&wkFfs0^D`!kF6BER?yNWo?$<_7Votqs?dx^rngTbU= zspB@y#$6fmx%9+1Wo;V4h27)hA54C;uR-=xhQ60xo?d|4ubrz-N+&Gw!4K?#}zT)r0KrGX{^LO|uZYWB2>CDm<3kNr`{@lop0QF60p{6=Q{DvWtAtD_u@r zbdX5zWxUBwEnl8z34f8rY>O5*4zdyXBTFTEC23Mey;)h)pL(;X(tHmtEQ3iImX?;T zc0(aKIeGml2Y)ac915Ku!nKMJpNQ{-?WPMa$3fF0!@|GAEafeWzQ^R>mi8Y0 zC6wJ0k$7}&rkBbqE^rh!q|FF+OKIELV*>KmQ7?cBTDmbQvDH3Pm4}B;+ZsMn^8<4O zgDMs~YGZ%7z3rxor>fFCPp1HHx%F~T+!h@En+t4w(YMrfhbY9?`^+9$-RlO?UYr#7 zyz4LirG2WZ`cX`EjMoWKZ7-_pJfv$x>y3hGRnfMq@-8h|IMeCjhqW)(%3A)n@7?Ok z=}Pn4I+%dAs=v?GEEr?lwKu+6--bXcqkTgu?-=q%AnAywRH%nWm<| zjITayUTT5G#l;DqxIJ&c1PY{GS!15o(k1=>z;(D5FzxE*y0hSJAJ+BQ{PfN`Bk&-wljv_`$E- zWgFD6yIkF&V*`Ym3Fpe|9E*?^2{(xsYDLTseUJTc0W#jFj2YntF}r4$1o*!jg^a-` zo%HVRb&{84C~?OpVxbKQD>>gOv>=!_=~9^hAw$A#Y31kR8zRM*-87cp@LW=Lt1NKU z)`&hnn2Sq}T+dn5cno|erqs~a`GSrM={@xEIiJUx?zRRN>$g2o=Iy&VN=(Je?Pa*K?C-vywIH0W?g^`d||MJ61QwDsy)vWm45k2Z4D zg~Y{GaooHQ*4ZBq-KGK@>kJ#fKbl2mF?V!aejVwKNQGcq?TGBoj}P+*jHaoDKIj^8>fljE@-OOM?;R*1~bo+g&jN>b90BdeKuDL1XLo(oGpePfrj4*+vRb4RaeNNIg{LJAQKIz4U9FYyS`{HzP#7&!Fx)rB4d^qVS z9i@pl`P}~Gx2%ML$gzPcsDuB@o)i#!{Pfhuo}k64grVZD(CFV!9}8=&J-wWWH@1%7 zD9OT!lS*L(mu`Q411NCb zo}v2Oo9A!eCOnSVweN5ERAngzl$D;iefu^$XxOz{Z)b!>&Z@?3&@9sT1cH;(>m3@} zTiit4z`rsyNSH1*f?%AM`nIMoViE*i*U$o_L_kV$AX#KHT!V_+qkCX(=jwtOhutvN z3(wme%DeaN^{4j(XY)QUnY_*p5kkKB{FkzKf@HL0`_S*x5h1UlH79f4>i;D|9wx zHX$K_t*ph~_bqQs73n>heoF76!b0I_}+woFSnuhhm7mx9jNZ}j)c6*G(ocpo@J_qkmP^QL%2-++3o@ zN9%_oQc~EhF|L8qxYW4K^>z5_VX>qEBOe=EP@eI0t^G%xOw-4B0Rh#|Jo{o(Qc_U1 zYonqg&z_y$A+R%Oamvlg`eNhkp3z(_3g!@Jl?bghoRc&^4K4akRXjA^yTjyj+&jE) zWutl+7bYP!(KC@=+tHq_7V}J0YN@-`pOpd)s}=YV{L*=%G~R&e z!^EKB%iq7}?R5C=-&vPB_^MuT<<}9so(}Qr{C4kY4Z8?O6SiGJp?8SaU}I+|H9iSF zL@vdzTe^>c=TBroDSNrQ76FB^z3G$u9rxL$qq^1`Au14`mufGOr^dpc{yyE-kly3GuLKK@~B*i)NV7&VL&=nw}f_wa7}#!r}}t4sh~A>k{BnioA0gVjFz z5#)5YwcFHd;^{zZt2r7+NV_;^>GJX>v7X?>sHPVOGpcMzLYJJM!lgUf_A0K2@&^>0 z8~WY%m}~dMIs2^aOd%_8Jps5NitBAp7x%H}Q_D9^iz`bmoz{iX9^4*Nw33OR#(DP= zyl#z0*!65N48B8860v(aZlwUvsyni6TZaRb6JShIf%69*vO2g{zV~CujBk`%{>W0V z5mq$8(qFK~7hHuXXAZKMvpJ<2J-#u!l{jJk^SBzN)EJAobA%xhgI&fbs{{axt-QM$ z+m+CX$>NSUzhW4a=Zfr~hoS2OiLm0aO&#~<53zfvr-ipc$2{icZg)N)!)rL=&gyVB z;pRAVj=|wpzo<-z&~)r_P^gN=DPhFWUF4*2<*2PT*d{$Tyg@4KCm~b;(ar&Mr9i*N zlR3ZQ<}=>OMF5Wg!){MEiP;^k3Z}-1_x)QP-f@RC<4*liJ>bo z-(W94X34lY1k4NI0fF?lyxkzoNBGQpJ&kE0fpS2d3%VjG+WE#1y5+TQ;hUvnK0{^l zpndi4{tF#@FRwP%K>d2tQs361(wF$fCS~98bCIk?fa(nF^$E-%zo~TTbAPAx z!Bg}V*?10Zkm!`YVQH9X-fw7 zYVYbs5j?Wla^5Mspb=YSMEWFW@5>!`EbHUSxiCf&zFoS5z2I+$`$s?Sn=6b4wJK_p zr|ktpxw|{+npjW9qZQ9K+GbTA6Fj^}y+}*fmUom6i^OqN_F-=_^)S=>)abv~MaY>5 z&l(nLdP7fBTw45!nishvuX>AC$6Vzqil{>*ipkn(j%S~%zlqDEB4Mw%d&QMfx7jhfTS=SQ9HIEN%848)p4RSN3S zFpnArqmt@l>&POyZ6{=`4f!ARbAnKu9EnJsXwUg6CFC>Kv~%RUKV0fUpfcTumJb0F za(*2{-k%7_?!Bx!)KM_K|7M>2-q4hj`BbWOlH7Hv@vYyM5uM~HG>S8i5bjYk>QgqE zLVChlE%=`=eh%i|xUX3(t1{jsAto@seRWf18+sV@&rQT(~h}xn;GNBET7b zYXF6rLqNqjLc&0Gz7V0=9_4lPm5Q8D&nK^A3lDS(&>QKer6DUF;->BA9mI6LPdj7N zMP3AHP4`1c1G+<7Lk>o5zpD39a?mv`_%7)^=p{_D^N&=~iuE|t`#Sn=&Hqh+N*B@B zPg!NHU8`JU79vI6B9ElhVk6YmB;y7TEMoLL1r)T4hr6R{Z+krCB`9GdXLSi?Wprpd zCh3Q1MDs!8UjJq!mK9?-5(pcGZak$U3`Wib7ulE3CcX3uM}0 zLzxWcZ}U`#=-&CMm&CKW7DSn-IDMJ|8T)Wp>uFgMTbal7g+({?V4PzddYC`;urLpI7mM*?gQQIihW~3)8{Gk^p{1PJ`uDM`l5aPLDdorY{u=^t>u z=n5EH`Z_M4l-j41Gd!WEt{=U(ik%cnbI;0!huknMiIsn2`}{0?ly@{^cNzu?MAC4dQ9pCFQVI@UL}&S(Y2EKvd==}P zSu4!!bg(Wqtp4s?TCI_sMQdR(zkfNQ>Q$aVzgba=%zP(#VZU}apY=#c@jMf^ypS{Ewqsv`f6&f4$Lol*8n{Xm~fBHO1&tk}zB3sD* z?u5I}oFRvEE^{2doFuO2x>R9?sqt2!iIaO=H$q+e<83g|_rKV0S-hB;kz`atR-^tSmm|BVbCfgi!2SdnhBjmZ$v!EE}!{e9oU!)cm)45-3 zj>?LG+w^f~JK}sIO8Vpusj0KuThv09w2agv__9qXcNsGxbBfX#FHmR7TYpQ!Qm5eu5 ze`G4p8T69i4%5AuIdF?4GG~nR3rIpYIsbFeN7<8Kr2mc0S?$lKt#-EqS32@a>YFMH zDFS->`Y;aYVeP8nLt&?s?IXnjj5QNi<2oGuaKO@45BHW!#j{58bz$Bnh$FOPSCMq7 zke7*(*N%C!NKGmvKABOW^g*WnNL2f%b-^T!L!}|XK*;kzc@^T4D31O>V4eBLaTi?8 zP?A}Hr-Q;GlkogoT{b+KQC@|dNGGlWsw>FV-PiS0dWY(M_Yrw;*yX$BxHg6#7Z?X@ zy)%?aGD*}Dw>mFfFrtsF@|0@h7AYl`-y0!$ zZJbqxGi{T^Jr?KXEmwl`I{NCJALYL6QNkvn+?xOC{t!X>i#@$txPno8_dd5%N_`@t zODjuXB07`)<|%;F2R}7=^X8#-=J!%pD3(FD@JD8f z-Z^0B;{|d+U&6U=_So;!*Ds|DA6zVZSLu46KEtu>8~mU&sM2k57^2LF8j@rWtzvzF zb3+XZV3E3eCBHqUTyR?B#BxKoSa`VKIT{th{~XeFt8dpyHn z4Gl@5I6QGg5mQe(z5}eyqnT>7yH1wevgkCK*24>ClRgI70zIwVU5%KpWA%~sUGJ0i$7CR$_58_M~j|#uY3y$ zpifVe4I`V$R;`hGB(Ky^@wTPy@=_eha_+tqROU>~6YC)~DH9x_{J!-RR~!ru5XaBU|k_P6e1ywVCmarT_@gQJeih{NCsN z8J~lTYYy(-cVMo_#>S=~rZ6jOSBr;S zz|PZDcO<~Y#f4T4mkBmV8#oCFz^%5QsawmG;U}1S^CtBrgTA8IYGL)9?}dHP^D?_L z-l{oJ1|Uj{ba$fhVglpvdjEuY)ZP^Eo&bKkx){+?gf+Y%NbL5r!(Uf>EOg8D!N;d^ zI`I}gOm)#xkAM}SA2t0>3yyuFoF+psxg+V+|J8j?Q&WSHnfZ9rU1*(KKp>BJ_c~H= zYip}5go^5=P)%HP3XI+0fuvbUe!Q)L-|Xm|yI}*Ducz;eIebM#d8L_P0Np~( zY0Yh&nhM>jF`v-Zz7gwdtZS0%2Uys*DBAE?dRB-bBZwfKGO2~1x zVXt;|v=G&6woztVkp`CMwBI<`F9pp6`8LrYQYS-QV=!g-kC%_lD0&R&PDv&v6kzbu zW90iD~FOcOz-=^SQz^uePJWJPiT3{aIqyLeuFbU$CLy`!|ETT#`^po7R$y zj2~2DZpR1vXU>TB{byK2{%*wg9zG@v8X6kBGra<;*Hr3+=oET+?k@sub91w_q~tY8 z5e4C7eVvZN5SKj`1_s&DXDIYw{d^Y=J#mEM;^CUZ_K%M_9zXsPMuR-M1jf)>5Ge6` zB;bbB_s4(B7Fb!g_x&Nn)ddh+r`AMI=9_|cWbdtsR^TH$f`5eOc-0dh^?E`?rc+&pPO6Rbi*_ppL)6sF($j{V2if5BU#n| z5oK3jQ6SFDe9~Qe2x>{l>5J7vAQjW!wE5E0(;B^xS=k?tSG(_qZco{>H|$@893d}p zxgvUHHpN?Z6W2CYwABm6_s6apD0NBoPxg+mvBR8C4i*kiJ!`csL$5Uo3!V^l_q4do zKS|%Bios3`rJ0P6aE;yGB_gIE8w%sPw_zf#;_QuvI%@AoF1s&A zM)b|ww+h$4=qafvSNgn7u7*^v4nLj%$K(Q{qnqcMJEv;0u$g`Y)3StdDs_@W1=TDw z`;)V@j04sKCgGNp3|!fh)2*_Q?44YrxZvZs`EWf+L6y z1Ls#jpQe!)x16yInDap-f#l_G*Y#!lRJ#}(pJJh!!gdC5z@!OHK1*SB{SC+hC zWnup#=6wJpt8nj^m*YVMmj`fxPb_HTVdRpCt$lhj>kb?&=F}XwJEigUT6XYXSniI+ zdF){{0!_O=y;W$$l-Z)ZmAv6xsO zclOjnL!Abt{9KfFO>OOi2M<ftROeX|~JIH!nccPUQ=Wh=93E;hZ1J{A6eI5cjp_>w!YNDZ#bl zPqEXVtx1VV&QDKEP!;$YL+in~!3twff&5*3mmSK-vd%hnbvhJv>;0|L($eh+b#=9& zaTc{unu*n&6`MlrsTg3JLi|HHDV-#9{yXKAoi5ip(Uvq*kA+A6fSP*tGlY_m+)@WY1>|Cwx3kaOd$&rVLFAt#Y_H*t1$)@(Qt^4fyu zl4k!J0kxpAkMsF$G&)CGznA<;`M%dh+9z1)BWqJu>3emwA4H&3T^>0y{93Nfp!^}d5P*)?Xt38Z*&K`ZQmul)p(K_k$QrK?lfTD2#d5V34_i*^V z<#^dDL`+2Z8;xYNM8-vEM@Mc{l*THT-nWn-oxLNXc9n!Q)RkY8F>Rxe-KBL4V1m7j zZ2aNZ|Kfa;$^V`64UvDWbAG<*>aw5A)%3_ITV7eQJ|RZfVrv{cyEA3TwX~`e#oTm~ zkN5UcwB0X|bA^f2)Jcx)hE1M_S?CuhW6;EM%U*Hr?e~U)^`OL+N}PBtP&E&!sqKn7YKZn(F@hlXS zL{)stuMgDBFDlB-lDS3d-q(CNog3C&70LGED^4%vb?_`vR>XWXM1+;x1W4Y?Ow+zRTjWHJVeG$)d^n zd%?*sG=kr@CmO7QMdVUyUhpIge(j4)5Xm1aP*8cH(`2XZ2^KigpxV^Rj%=8zj(1xa z&F;>^)mC%BQ5&-1qIW6?Mp$isF=Idry_j(~?n5`muFBT_h0|T>j?KxD7f4BlnYjE<@Z3W)^~d5V;JRWBo!fZv^@Gp>1)xN`i6>A z;HCH+mRNIkF>(}>C{M@p@CXPx8hy}vlf^sPSt>V^Zk=9XWe-rSvnv`vgJF*PG*`B_((K#>gYgB?vPIVA5Qzwmj!qNJz0U;-puj#8UM3*b!m2i|ra~>x!S7hyh znT#R!N~Yz?#j>vq_H+XuC}%{8pD-XdY`fCZ(?Kn+InT|W%S&Xf>uj<&vIn{%LqoHB zGUv_aw>D1<^|tLq4cT{pT-<3B7w}%8KeF&;WnhQ_(nU|R^P~Emy3LvDxzWv|jN{A8 zOX?(vCy##4{SL_O**G36&dMs#EW;}d-C3|oNKCYX%rlsKZsR%ZqfV zwrxD#K;><^a2<_9lu&ny+^&INU6v}ODotbhZDbasfpB81rWl z^ZgUaJ*jDZcMkosjJ&ck#x9D#d><}ZuVKj}KE6W~F`KiCv#E(mbOW;Pn59;!SMd2= z9^A?agPe_R$DATt?6N*>7y;v9g`|NQ7i{vaS^11$2I=w-8sNVan#!%A6}% zkOTU&M6Aqff4?JS^C9jInYh2i@v%_%w}Sm=f!`?at6pH?Z(Q$8b%{5M-+{Q+t%pg7 zdi?o*GV7TgunyxUP3<{4JVZEqj#6`m!6G80!DN?!Otj*v`7fK-{j{`0JqvjK%q;oJ z>15hl$Jt_xyS0k-a&vO#C#R_%b8+L%%(RGV7Ch>az9%;oLwcK7t5`Z-yu!~m8 zSvW%HhUGXDYH3kw+OfG;PaDmhy(dGz?da;uz)hH5F*79`Bab8Hx2?o7REI0OuxA#e z9qO+yBd0&;>QMp0K&fG;T3P0sN4X{PQ>So8N9|w%9(v_Y9O(#p zb(!gEa#)}K-gNn4`!_Pi@GnC{d}|ymA^{MoRYGhT0`b#F_67RYq1y@#KTdbV9H$Iy z|DV`7BQD*+|AU+xQPP?Ezj1SPwyMcr-so^ri#k&_mOc^lLzU)m?{;S5nv@>Rb`S*p zUW{n|;0wJ{VJDt%Zb=kR2eq(~u{$=i{;oTxsqUgJW%i6=zx1|}!Dv9D9 z2H2p>)KP>s64^*N1-=yajwY#?`p)NOWYCxud$mqOBBx8t7|>HxG$sK3kbsAl8Uuk$ zm(?DwbRf!9C9ghB2LX4)x^LTv%ct4{2Jf?u$t}A^l1vSC-FFo6u8xky1lPxosV}8; zo)wNZ94!V5aqtl3bF#X>JDfOj;T+ zz;^6FDwE=xg@w(oE z#j$%H4`EByD;BNCgcKo|ksb1JALQ4Gi zsYv|KU}5z4^*`iK+?de?x?|98ws8^zrh$Jx^>esh>al>Y0)O4J`ez4g_d)yoeGC5Y z-!J`{LU5w+aK3-`{GxAGg^YWVho^w^`ZGof30~YB~HM$!N_Fs0P4xDfboI>Z-U=qjFlF@4m5t6LJ742jGW!3)5z^ z`$_f`YU^;xa(yiSm|Ug$)8r3w?ZC09g}Q}o(GzRrNY1$J z`^b`oJrhsNxNqLtqMfH z>E239H-k7g6Ml006L;(zruQ-C$D+*ju}(XaQD9qe$gR+c)KGgO+G+a&3YCCej72q{ zKqa&^3mK`Nal%knQ(-PN$y5*Li-pz>4-V7Qnn@e?*l(?gBFxPxMvEm&ppx&!{-nup z4Xj->HwR2rIp`k^(!~YZv$GQh8E9W_T=`xk>DF-5)0^(+7nh;=iiur?xYX&UzV)O#`bjA-M~Lb`){X9fXYBUEt#AMx!5W{Y04jY=}f%- z(6O=fhpge`8{|s;ThtDj`y4utZ(zgOC=POBKINxl)79eg017DC(nLI)Yj{S9!Bny8 zEE|usj|yb?{Q=5{^G^YU{%#VL%@v~HwMs!XS_XVfvq8o;&2~sLM+rNn2gjdj0z0gNO3AaFf!Sv@|+^ z>D-8Z0dw2hPLO^rjXSZ!KE#_7-G zsn>brRdChUc4kVFO;Rzi#J5(vDlRkfu|SKTjTfjk@i{R@h<8x&=jM0k59Q^N(#EP3 zuKBZj6aTJ7r}nKe+Yj#RhS~rvI<=^e>#UxI$3j+*?yOf}F7b8ETl?$!i{98LOObs| z0Nlp=LyS`>%N8!bw75vfsn>5+;owlkX~9_y8$tjv&(4N7tg-O}=kD4cN)uDJw;)g9 zFMuR@xre^}*4dSlmE9&U+4Vl?2B8Dv;+o;F2F}jTSON*^>uQ0Gm3X>dD6j?KbE_QM z)hwl@0~Di0W#y5BZWb0KVO5ut0eZ0~+H!CYBVcXVrkVhUB7`VD`Dz_f;W@SU2 zHL4<2GdII+a3o)IAig?GX#16>7SR3Oct#Re3xy1-tH2uJWNpn#RaMm)*F$_(j;1JO zy*el7bb{1C!evF=*v(fDEa}EEWWjyB7wbve_%TFwDN_+PcRioZrd2%Pr=#(v%;VMN z_h0;9R}riBDBo2}w(H_}m>y*3^%y%m3LOiNsrnHE|MIMNyPT{HPC#?_w{MPc{lutP znTyrlc+v+Co)e4&Gn%CT+WXzfg@bco4NvUeScC&5Yi8;x-^C8Ti-QgK*|$^gy1)noVH0emu^fZ3>4DU~2ikJXFrb^AT?mi0 zRE`Iu-n_nS zPxh7{%6=atleRnR^ThQ0*+^d8T-9YS!pYF*D)u)QTngwX7-3V)`t|242SNZ8O6o8( z*y}zH`hY^AMvDzk&IdDrC+5w;q?+4C_7vD(t9^B+&fC@puLG&;;QwV2A``I~ zh)*U$mxLss9KN2ZhIx8<-h=qJW&DRRfZZPCeEhWqln$L20A!lf@XGS?)Bb1@aFyWR zzVvgSQ=VF4`UY+QReAZ8Z2;mwmyrY5&=4|bARW0qkv_C(_s^q6g_w-k_Zpob^da!u zMF(9lg8_>};rY|oj-V2c&-SE-GcFoB+IU-LYZqQ}WfCT`Sy3@r`*g5F-7EHh*}Xng@3zg#%Kk!G8MR$)*x1$#esDhtxZUav`%dhjz$RwzC~%#Kk%C{KOR9 zSKmCtwEv5k@xe1dQmHET-P^<983<>wUQhPBqA|%u%}Uz0_3GCH)0kyn3^laRs8G{= zkwLv;qV%w@eU-Dub^QP+7}rXBZ@Hf+tMNhDwHI#eG#-j0PBoa7xx3&&;#CA(i z{Fud;J_xp8Qs^1<+8uEa1;!M6Fr6{F!!MPsQ|;E%hPR2jM6HS=JzSNx*n$mxeb)&G zHIKefP3`UPJDl37XTKkK!fHY$;d|anYYTtPx=lpViT@qbe=0ONd9GzB8|(IIeGPY| zcH`Cg$ncMm7gebv)_}w*FIRVS8(ry%KWfXxB@ut-Zrhs--`fql${gYMRN>NBd_6v2&5y%v{Vb^BKC8FY@ueMycgz83rJ(F7tfw=k$*fwPfp}CWs_q71@Wl%M{qNwKhm2(@d}Y)b&+b>qt4j*Mfki*|j;@f;Rcw}vKflnz4U7#z3aCw|# zBfCojY5a_S4Y_o&vEBg_r?PTh)TK|YUQ%UX*HUIiM*orG)U(g{g>WaUH}X=C_(b-W zS=86wUv6#Lnbaa98b8!}ZI7^Nv9hw(2)bi+Dh40Als#)sx0)K6QMV|riSzh%UXVC_ z^-C`KE?!s_5~QjD-*8F2y(RwH`E&jH1SugCaARuP zbZ{i5xdrE=L!_`4u%8p6K{%G?o0TRDUXAeAXLO9uk4pv;k&iM1!7&5>j~0ZPS%%|< z?`B=4Ek&Yt?C4j8uKu#^DysVFnHdJUQZZD^2x5Fc67YE}B8(g&jK`M&x1o}7Kh#bg z8)useD+ed%MAcGQLBW=!+crrsLn`gBk68FA3lny{$r3IOAPeF)L{$I!7CHb!%b$M4 zdRsdkNbTpb*s1_P&-`r--a+1Gdio^aomx2>-=nZZv3H{S#>NYvh>eKR{Ll9v1Aj#S zxQ&5zUFJzts?;EdoA>PVUC+f1W*O-PW*TU>K|z`!yx!0cfdGiOYMl7l0isLtYA@ZO zxOzV{HdjHS>DDv3kjRd0f}L3db>Bv5Fi$kSz+><1V`Kee>aE12E~}$v);)v1B$xME zAn;^|Uz^j>AiaQx1bupE1EE%BodrD) z{C(fTGXE^K*{kq##o5WQ(rr4PTaX&_e-|W+)Nn^C-Z*q7_!jTQ*S`P?3wygkEv19C zRc6LQcmBeaMk?Xl+mMuze+evFKp$TjxehSJTzRXr7u?bqV93eo2lgA+vQXuj;fm{? zes!T0)N4dnm@yiAyI9edf)0HUXOm$w{`?sNa3t~S&4I*R)kH<2wG36>&UF_$Ze3Y*ow5M z)xQ}~mo^BZ^Dp|g2J)-HIN&y;AaI+bwUhV*j)6rivQPxpBAmn=;Ram5BXwdzktZP`qC zi*$I5BB?_MEn6LK+Y;_`6MPi_Dp=j}VvlHFsxlJ~#p~8g3~LF!iGX-gmaE}@s?9LI zhF*a-qv7m??BA^H7JtJ*F{=cPd^L;#u`)b*llrun=+1A^khq(M(h{x{5Gw?653XoB zqO4q+;rO3*h@^0b6Md!JBi58N3%-lZFMEOr0(vds+`P_X%*0Jg%ppq9P=IQ zJSN-#9)W|I-V_23UJ4*HsgvsaiU)j%P)?9kNtX-ICw~>@sz?^L&SjON-?e1N7V_4G zo4W6U5_WxjO(Z*qN+J3b8 zojB`7YVXW*rPwp!Mc}H7VkpDuN;B&6zH!~tj3OS{rmeKjnC{ViM{7Ms#SSnr7W}}` zXoq+CvnDwZ^orN0mY3j6@OLCeh#A#tKlG6rp%Fv;Lz$`~2PTWl(w=8IvHy8dRCtHK zMw7w%ZQ=Y%VO=~aPdrZ%79@pl06mT_h#&r>YJ;OSypsn;AhpyjWY`AV=GML9A2NLI zK6fusRtCzvy{u9}_kO_zAV2S05S_#-ot?ZUvGf&>#A1h&n!9qRxay4Oa&q-EEvh%D z{Zfxgv$9&angMY2Nu-R0*+Sh0ArL)AKcApP_Z zWWlIWhdJ&p12Y96a7pMz#SV0jJV@@+CW2Y3sc5ppkR(|RvvIL~fuK7?^ zYdCU%b@YlrZT0gFx9AP(5s9f1fX;HXSRcKbNqSYk#cZ6959zlSHO`-9^+AIa%0gv& zx}h1!lONV^ZQK(JIGhi;#DOFOGkf;>)3AQl7Z-K+iw2@9P)UQT@UUv53II?1VboBY zmz*VA90QV$lg4@8Am3sVd-bz#W*xEzw=(Ocf1xOdL9xCr^t!>RD&&{E6}$COR7`wG zU=k4ZFHB?@%tA5PjR48N%7x>IEu=G_-&Yvt96v6F2DQh)LHg*1B$R*!pvL z(YQStWKB4+-22nbPpfOo{kk-On!H$RHZ%p@#eE#q%a{(Y0Xw*PrC@RW%2 zQ}Ds-_TLx}@b2FM-2cBeT(}0eoz7cu9+)=^Cv-?{h$rH{l_28ggcW2|o)<|Q GefVE=nv{Y7 literal 0 HcmV?d00001 From ec632fe98c3f5d9d8f9dc323bba4df40c22855ee Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 20:36:15 +0000 Subject: [PATCH 142/362] feat: open-practice time limit as a single Minutes input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hours:minutes time-limit input on the open-practice round form with one "Minutes" number field. The round still stores `time_limit_secs = minutes * 60`, and a blank field still means "no limit" (the RD ends the practice manually). The value is held as the raw input string so blank is distinct from 0 (both map to no limit on save). Updates the EventRounds unit tests (90-min entry → 5400s, blank → undefined, edit round-trips into the single Minutes field) and the open-practice e2e (asserts the single Minutes field, no hours input). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventRounds.svelte | 44 ++++++-------- .../apps/rd-console/tests/EventRounds.test.ts | 57 ++++++++++++++++--- frontend/e2e/open-practice.spec.ts | 4 +- 3 files changed, 68 insertions(+), 37 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index fdd113c..58a745d 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -434,12 +434,13 @@ let startMaxMs = $state(5000); // randomized start hold: longest let graceSeconds = $state(3); // grace window after the win condition, in seconds // ── Open-practice duration (open-practice refinement) ──────────────────────── - // The **time limit** for an open-practice round — the practice duration, entered as hours:minutes. - // Blank / both zero = no limit (the RD ends the practice manually). When set, the runtime auto-ends - // the practice once the elapsed running time reaches it. Only shown / sent for the open-practice - // format (it has no win condition; the time limit is its only auto-end). - let timeLimitHours = $state(0); - let timeLimitMinutes = $state(0); + // The **time limit** for an open-practice round — the practice duration, entered as a single + // "Minutes" field. Blank = no limit (the RD ends the practice manually). When set, the runtime + // auto-ends the practice once the elapsed running time reaches it. Only shown / sent for the + // open-practice format (it has no win condition; the time limit is its only auto-end). Stored as + // `time_limit_secs = minutes * 60`. Held as a **string** (the raw value) so a blank field + // is distinct from 0 — blank ⇒ no limit, where 0/blank both `buildTimeLimitSecs()` → undefined. + let timeLimitMinutes = $state(''); // Open-practice format (open-practice Slice 2): swaps the class/seeding inputs for the // active-channels picker, and is submittable on a label + at least one active channel (no classes). @@ -531,8 +532,7 @@ startMinMs = 2000; startMaxMs = 5000; graceSeconds = 3; - timeLimitHours = 0; - timeLimitMinutes = 0; + timeLimitMinutes = ''; // blank = no limit } export function openAdd() { @@ -589,10 +589,9 @@ grace && typeof grace !== 'string' ? Math.round(grace.Duration.micros / 1_000_000) : 3; // Open-practice duration (open-practice refinement): reflect an existing time limit into the - // hours:minutes inputs. Unset (no limit) reads back as blank (both zero). - const limit = round.time_limit_secs ?? 0; - timeLimitHours = Math.floor(limit / 3600); - timeLimitMinutes = Math.floor((limit % 3600) / 60); + // single Minutes input. Unset (no limit) reads back as blank (`''`). + timeLimitMinutes = + round.time_limit_secs != null ? String(Math.round(round.time_limit_secs / 60)) : ''; formOpen = true; } @@ -665,13 +664,12 @@ } /** - * The open-practice **time limit** in whole seconds from the hours:minutes inputs, or `undefined` + * The open-practice **time limit** in whole seconds from the single Minutes input, or `undefined` * when blank / zero (no limit — the RD ends the practice manually). Open-practice refinement. */ function buildTimeLimitSecs(): number | undefined { - const hours = Math.max(0, Math.round(timeLimitHours || 0)); - const mins = Math.max(0, Math.round(timeLimitMinutes || 0)); - const total = hours * 3600 + mins * 60; + const mins = Math.max(0, Math.round(Number(timeLimitMinutes) || 0)); + const total = mins * 60; return total > 0 ? total : undefined; } @@ -897,25 +895,17 @@ {/if} {:else}

    - - - +
    {/if} diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 7ef48e0..14040e6 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -470,17 +470,18 @@ describe('EventRounds (open practice — no win condition + time limit)', () => target: { value: 'open_practice' } }); - // The win-condition input is gone; the Time limit appears instead. + // The win-condition input is gone; the single-minutes Time limit appears instead. await waitFor(() => expect(screen.queryByLabelText('Win condition')).toBeNull()); - expect(screen.getByLabelText('Time limit hours')).toBeInTheDocument(); + expect(screen.getByLabelText('Time limit minutes')).toBeInTheDocument(); + // No separate hours field anymore — minutes only. + expect(screen.queryByLabelText('Time limit hours')).toBeNull(); // Pick both active channels (the picker is driven by the primary timer's node seats). await fireEvent.click(await screen.findByLabelText(/Channel .*5658/)); await fireEvent.click(screen.getByLabelText(/Channel .*5800/)); - // Set a 1h 30m practice duration. - await fireEvent.input(screen.getByLabelText('Time limit hours'), { target: { value: '1' } }); - await fireEvent.input(screen.getByLabelText('Time limit minutes'), { target: { value: '30' } }); + // Set a 90-minute practice duration (stored as 90*60 = 5400s). + await fireEvent.input(screen.getByLabelText('Time limit minutes'), { target: { value: '90' } }); await fireEvent.click(screen.getByRole('button', { name: 'Add round' })); @@ -490,7 +491,45 @@ describe('EventRounds (open practice — no win condition + time limit)', () => expect(req.win_condition).toBeUndefined(); expect(req.classes).toEqual([]); expect(req.seeding).toEqual({ AllChannels: { channels: [0, 1] } }); - expect(req.time_limit_secs).toBe(5400); // 1h30m + expect(req.time_limit_secs).toBe(5400); // 90 min * 60 + }); + + it('treats a blank time-limit minutes field as no limit (undefined)', async () => { + const created: RoundDef = { + id: 'op2', + label: 'Open Practice', + classes: [], + format: 'open_practice', + params: {}, + win_condition: 'BestLap', + seeding: { AllChannels: { channels: [0, 1] } }, + channel_mode: 'PerHeat', + staging_timer_secs: 300, + start_procedure: { mode: 'randomized-delay', min_delay_ms: 2000, max_delay_ms: 5000 }, + grace_window: { Duration: { micros: 3000000 } } + }; + const createRoundImpl = vi.fn(async (_b, _e, _req) => created); + const { session } = makeTestSession({ + ...opImpls(), + createRoundImpl, + event: { ...EVENT, rounds: [] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { + target: { value: 'Open Practice' } + }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'open_practice' } }); + await waitFor(() => expect(screen.queryByLabelText('Win condition')).toBeNull()); + await fireEvent.click(await screen.findByLabelText(/Channel .*5658/)); + await fireEvent.click(screen.getByLabelText(/Channel .*5800/)); + + // Leave the minutes field blank → no limit. + await fireEvent.click(screen.getByRole('button', { name: 'Add round' })); + await waitFor(() => expect(createRoundImpl).toHaveBeenCalledTimes(1)); + const [, , req] = createRoundImpl.mock.calls[0]; + expect(req.time_limit_secs).toBeUndefined(); }); it('reflects an open-practice round: shows its time-limit summary and no Fill control', async () => { @@ -549,11 +588,11 @@ describe('EventRounds (open practice — no win condition + time limit)', () => render(EventRounds, { session }); await fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); - // The time limit seeds back into the hours:minutes inputs (5400s = 1h 30m). + // The time limit seeds back into the single Minutes input (5400s = 90 min). await waitFor(() => - expect((screen.getByLabelText('Time limit hours') as HTMLInputElement).value).toBe('1') + expect((screen.getByLabelText('Time limit minutes') as HTMLInputElement).value).toBe('90') ); - expect((screen.getByLabelText('Time limit minutes') as HTMLInputElement).value).toBe('30'); + expect(screen.queryByLabelText('Time limit hours')).toBeNull(); }); }); diff --git a/frontend/e2e/open-practice.spec.ts b/frontend/e2e/open-practice.spec.ts index a8f5dce..0f2cbc5 100644 --- a/frontend/e2e/open-practice.spec.ts +++ b/frontend/e2e/open-practice.spec.ts @@ -62,7 +62,9 @@ test('RD defines an open-practice round, picks active channels, and runs a per-c await expect(form.getByRole('group', { name: 'Active channels' })).toBeVisible(); await expect(form.getByLabel('Eligible Open Class')).toBeHidden(); await expect(form.getByLabel('Win condition')).toBeHidden(); - await expect(form.getByLabel('Time limit hours')).toBeVisible(); + // The time limit is now a single Minutes field (no separate hours input). + await expect(form.getByLabel('Time limit minutes')).toBeVisible(); + await expect(form.getByLabel('Time limit hours')).toBeHidden(); // The primary timer's Raceband seats label by band + channel · MHz (node 0 → R1 · 5658). const r1 = form.getByLabel('Channel Raceband R1 · 5658'); From 46852629ebabcd6e1e67267304faa396cf238b02 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 20:36:15 +0000 Subject: [PATCH 143/362] chore: gitignore Playwright e2e-artifacts dir Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1c54439..40c6bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ frontend/test-results/ frontend/playwright-report/ frontend/blob-report/ frontend/.playwright/ +frontend/e2e-artifacts/ From 3d49be5fd8967ecf97aeb4a2c9d6d4e9e71465b4 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 20:48:41 +0000 Subject: [PATCH 144/362] =?UTF-8?q?fix(open-practice):=20overlay=20reports?= =?UTF-8?q?=20real=20heat=20phase=20(Running=E2=86=92Unofficial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-practice live overlay built its `LiveRaceState` by feeding `live_state()` a synthetic slice with a hardcoded `HeatTransition::Running`, so the overlay always reported phase `Running` — even after the heat's time limit fired `Running → Unofficial` in the log. The console race clock (`useRaceClock`, which freezes on Unofficial/Final) therefore never froze and free-ran past the time limit. Thread the heat's real current phase into the accumulator: the source bridge records the heat's `HeatStateChanged` transitions (e.g. the time-limit `Finished`) into `ActivePractice`, and `synthetic_events()` emits the actual transition sequence so `live_state()` computes the true phase. The overlay now reports `Unofficial` once the heat finishes (and `Final` if finalized) while the per-channel laps stay visible — the accumulator is still only cleared on a true terminal / abort / restart / new-heat, never on `Running → Unofficial`. The client clock ticks during Running and freezes at the practice duration when the phase flips to Unofficial. Tests: open-practice accumulator unit tests assert phase `Running` while live and `Unofficial` (laps intact) after a `Finished` transition; the race_flow e2e drives an open-practice heat to its time-limit `Unofficial` and asserts the served/overlay live phase is `Unofficial` with the per-channel laps still carried (the clock-freeze precondition). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/source.rs | 20 +++-- crates/app/tests/race_flow.rs | 52 ++++++++++++ crates/server/src/open_practice.rs | 128 +++++++++++++++++++++++++++-- 3 files changed, 189 insertions(+), 11 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index bf0b5bd..38da815 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -991,11 +991,21 @@ fn handle_transition( // transition and push the now-idle live state. 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`). - if !matches!(transition, HeatTransition::Finished) - && state.open_practice().is_active(&heat) - && state.open_practice().clear() - { - state.wake_streams(); + // + // Open-practice overlay-phase fix: the kept `Finished` step must also move the overlay's + // phase. Thread the real transition into the accumulator so its synthetic slice reports + // `Unofficial` (not a hardcoded `Running`) — which freezes the console race clock at the + // practice duration while the per-channel laps stay visible. The true terminals below + // clear the accumulator outright, so they need no phase threading. + if state.open_practice().is_active(&heat) { + let woke = if matches!(transition, HeatTransition::Finished) { + state.open_practice().transition(&heat, transition) + } else { + state.open_practice().clear() + }; + if woke { + state.wake_streams(); + } } } // Staged is pre-Running: the heat isn't emitting yet, but it is the moment to **tune** the diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index 1b57d41..bd4cfce 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -1271,6 +1271,58 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() finished, 1, "the time limit auto-ends the practice exactly once" ); + + // (d) Open-practice overlay-phase fix — the clock-freeze precondition. The served/overlay live + // state (the same accumulator the snapshot/stream serve in place of the log fold for an active + // open-practice heat) must report **`Unofficial`** once the time limit closes the race — *not* + // the old hardcoded `Running` — so the console race clock freezes at the practice duration. The + // bridge threads the heat's real `Finished` transition into the accumulator, which keeps the + // per-channel laps (the accumulator is only cleared on a true terminal / abort / restart). + // + // The bridge processes the `Finished` it observes one poll after the log carries it, so wait for + // the overlay to settle on `Unofficial` rather than reading immediately. + let target = heat.clone(); + let practice_overlay = registry.resolve(&event).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + loop { + let overlay = practice_overlay.open_practice().live_state(); + if overlay + .as_ref() + .is_some_and(|s| s.phase == gridfpv_server::snapshot::HeatPhase::Unofficial) + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the open-practice overlay never reported Unofficial after the time limit (was {:?})", + overlay.map(|s| s.phase) + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let overlay = practice_overlay + .open_practice() + .live_state() + .expect("the open-practice overlay survives the Running → Unofficial step"); + assert_eq!( + overlay.current_heat.as_ref(), + Some(&target), + "the overlay still reports the practice heat" + ); + assert_eq!( + overlay.phase, + gridfpv_server::snapshot::HeatPhase::Unofficial, + "the overlay reports the heat's real phase (Unofficial) so the console clock freezes" + ); + // The per-channel laps are still carried through Unofficial (the clear-on-stop kept them). + assert!( + !overlay.progress.is_empty(), + "the per-channel laps stay visible through Unofficial" + ); + assert!( + overlay.progress.iter().all(|p| p.pilot.is_none()), + "open-practice rows stay unbound (per channel)" + ); } /// `POST …/rounds` asserted ok, returning the created [`RoundDef`]. diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs index 36e65db..af08453 100644 --- a/crates/server/src/open_practice.rs +++ b/crates/server/src/open_practice.rs @@ -36,8 +36,8 @@ use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition, Pass}; use crate::live_state::{LiveRaceState, live_state}; -/// One open-practice heat's **in-memory** state: the active heat, its channel lineup, and the -/// accumulated lap-gate passes (never logged). +/// One open-practice heat's **in-memory** state: the active heat, its channel lineup, the +/// accumulated lap-gate passes (never logged), and the heat's **current loop phase**. #[derive(Debug, Clone)] struct ActivePractice { /// The open-practice heat currently accumulating laps. @@ -47,15 +47,25 @@ struct ActivePractice { /// 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, + /// The heat-loop transitions this open-practice heat has gone through *after* `Running`, in + /// order — threaded in by the source bridge as it observes the heat's `HeatStateChanged` + /// (open-practice overlay-phase fix). Empty while the practice is live (`Running`); gains + /// `Finished` when the time limit (or a `ForceEnd`) closes the race, so the overlay reports + /// `Unofficial` and the console clock **freezes** — and any subsequent step (e.g. `Finalized`) + /// the heat reaches while its laps are still shown. Re-synthesized into the live fold so the + /// overlay's phase tracks the *real* heat phase, not a hardcoded `Running`. + transitions: Vec, } impl ActivePractice { /// Synthesize the event slice the live-state fold consumes: the heat's `HeatScheduled` over the - /// active channels, a `Running` transition (so the live phase reads `Running`), 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. + /// active channels, a `Running` transition, the **actual subsequent transitions** the heat has + /// reached (e.g. `Finished` once its time limit fires, so the live phase reads `Unofficial`), + /// then the accumulated passes. Reusing [`live_state`] over this slice gives per-channel laps + /// with `pilot: None` *and* the heat's real current phase for free — the same fold the logged + /// path uses, no second lap definition and no hardcoded phase. fn synthetic_events(&self) -> Vec { - let mut events = Vec::with_capacity(self.passes.len() + 2); + let mut events = Vec::with_capacity(self.passes.len() + 2 + self.transitions.len()); events.push(Event::HeatScheduled { heat: self.heat.clone(), lineup: self.channels.clone(), @@ -63,10 +73,23 @@ impl ActivePractice { round: None, frequencies: Vec::new(), }); + // The race is live from `Running`; the bridge threads in any later transition (e.g. the + // time-limit `Finished` → `Unofficial`) so the overlay phase — and so the console clock — + // follows the heat. The passes follow the transitions; the lap fold is order-independent + // over them, and `live_state` resolves the phase from the transition sequence. events.push(Event::HeatStateChanged { heat: self.heat.clone(), transition: HeatTransition::Running, }); + events.extend( + self.transitions + .iter() + .copied() + .map(|transition| Event::HeatStateChanged { + heat: self.heat.clone(), + transition, + }), + ); events.extend(self.passes.iter().cloned().map(Event::Pass)); events } @@ -102,9 +125,31 @@ impl OpenPracticeLive { heat, channels, passes: Vec::new(), + transitions: Vec::new(), }); } + /// Thread one heat-loop `transition` for the active open-practice `heat` into the overlay + /// (open-practice overlay-phase fix). The source bridge calls this as it observes the heat's + /// `HeatStateChanged`, so the overlay reports the heat's **real** current phase: `Running` while + /// the practice is live, then `Unofficial` once the time limit (or a `ForceEnd`) closes it — + /// which freezes the console race clock at the practice duration — while the per-channel laps + /// stay visible. The accumulator is **not** cleared here (the clear-on-terminal path drops it); + /// `Running` is implicit (the base of every synthetic slice) so re-recording it is a no-op. + /// + /// A no-op when `heat` is not the active open-practice heat. Returns whether the overlay phase + /// actually changed, so the caller wakes `/stream` only when the live state moved. + pub fn transition(&self, heat: &HeatId, transition: HeatTransition) -> bool { + let mut guard = self.write(); + match guard.as_mut() { + Some(active) if &active.heat == heat && transition != HeatTransition::Running => { + active.transitions.push(transition); + true + } + _ => false, + } + } + /// 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 @@ -162,6 +207,7 @@ impl OpenPracticeLive { #[cfg(test)] mod tests { use super::*; + use crate::snapshot::HeatPhase; use gridfpv_events::{AdapterId, GateIndex, SourceTime}; fn chan(i: usize) -> CompetitorRef { @@ -208,6 +254,9 @@ mod tests { .live_state() .expect("an active open-practice live state"); assert_eq!(state.current_heat, Some(heat)); + // While the practice is live (no transition threaded past `Running`), the overlay phase is + // `Running` — so the console race clock ticks. + assert_eq!(state.phase, HeatPhase::Running); // Two channels, each a row; neither bound to a pilot (open practice is per channel). assert_eq!(state.progress.len(), 2); assert!(state.progress.iter().all(|p| p.pilot.is_none())); @@ -244,6 +293,73 @@ mod tests { assert!(!live.is_active(&heat)); } + #[test] + fn finished_transition_reports_unofficial_with_laps_intact() { + // Open-practice overlay-phase fix: when the heat's time limit fires `Running → Unofficial` + // (a `Finished` transition), the overlay must report **`Unofficial`** (so the console clock + // freezes) while the per-channel laps stay visible — not the old hardcoded `Running`. + let live = OpenPracticeLive::new(); + let heat = HeatId("open-practice".into()); + live.begin(heat.clone(), vec![chan(0)]); + live.record(pass(0, 1_000_000, 0)); // holeshot + live.record(pass(0, 4_000_000, 1)); // +1 lap (3.0s) + + // While racing the overlay is `Running`. + assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); + + // The time limit closes the race: thread the real `Finished` transition in. + assert!( + live.transition(&heat, HeatTransition::Finished), + "threading Finished into the active open-practice heat reports a change" + ); + + let state = live + .live_state() + .expect("the overlay survives Running → Unofficial"); + assert_eq!( + state.phase, + HeatPhase::Unofficial, + "the overlay reports Unofficial once the heat finishes, so the clock freezes" + ); + // The laps are NOT cleared by the Running → Unofficial step. + let n0 = state + .progress + .iter() + .find(|p| p.competitor == chan(0)) + .unwrap(); + assert_eq!(n0.laps_completed, 1); + assert_eq!(n0.last_lap_micros, Some(3_000_000)); + + // A subsequent `Finalized` step folds to `Final`, still carrying the laps. + assert!(live.transition(&heat, HeatTransition::Finalized)); + let state = live.live_state().unwrap(); + assert_eq!(state.phase, HeatPhase::Final); + assert_eq!( + state + .progress + .iter() + .find(|p| p.competitor == chan(0)) + .unwrap() + .laps_completed, + 1 + ); + } + + #[test] + fn transition_is_a_no_op_for_a_stale_or_idle_heat() { + let live = OpenPracticeLive::new(); + // No active heat → no-op. + assert!(!live.transition(&HeatId("h1".into()), HeatTransition::Finished)); + + live.begin(HeatId("h1".into()), vec![chan(0)]); + // A transition for a *different* heat is ignored (the overlay phase doesn't move). + assert!(!live.transition(&HeatId("h2".into()), HeatTransition::Finished)); + assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); + // Re-recording `Running` is a no-op (it is the implicit base of the slice). + assert!(!live.transition(&HeatId("h1".into()), HeatTransition::Running)); + assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); + } + #[test] fn begin_replaces_a_prior_heat() { let live = OpenPracticeLive::new(); From c4e17ea32be324561e8983fae35c4bc062d741f9 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:05:44 +0000 Subject: [PATCH 145/362] ux: copyable event-name box + copy button in the delete-event dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hard-delete confirmation requires re-typing the event's exact name to enable the red "Delete permanently" button — deliberate friction we keep. This makes grabbing that name easy without weakening the gate: - The exact name now renders in a dedicated selectable box (a styled with `user-select: all`, so one click — or triple-click — highlights the whole name). - A copy button next to it writes the name via `navigator.clipboard.writeText(name)` and shows brief "Copied" feedback (reverts after ~1.5s). It gracefully no-ops if the Clipboard API is unavailable (insecure context); the selectable box stays the manual fallback. The type-to-confirm input and the gated red button are unchanged. Tests: a new EventPicker unit test asserts the box renders the name and is `user-select: all`, the copy button calls clipboard write with the name and shows "Copied" (mocked navigator.clipboard), the gate still requires typing, and the no-clipboard path no-ops. The delete e2e spec is extended to click Copy (with clipboard permission granted) and paste the name to confirm. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventPicker.svelte | 107 +++++++++++++++- .../apps/rd-console/tests/EventPicker.test.ts | 117 ++++++++++++++++++ frontend/apps/rd-console/tests/support.ts | 13 ++ frontend/e2e/delete-event.spec.ts | 25 +++- 4 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 frontend/apps/rd-console/tests/EventPicker.test.ts diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index 7292dcb..6663e8a 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -62,16 +62,52 @@ !!deleteTarget && deleteConfirmName.trim() === deleteTarget.name.trim() ); + // Brief "Copied" feedback for the copy-name button (reverts after ~1.5s). Tracked so the button + // label can flip and back; the timer is cleared on re-copy / dialog close so it never lingers. + let nameCopied = $state(false); + let copyTimer: ReturnType | undefined; + function openDelete(ev: EventMeta) { deleteTarget = ev; deleteConfirmName = ''; deleteError = undefined; + resetCopied(); } function closeDelete() { deleteTarget = undefined; deleteConfirmName = ''; deleteError = undefined; + resetCopied(); + } + + function resetCopied() { + if (copyTimer) clearTimeout(copyTimer); + copyTimer = undefined; + nameCopied = false; + } + + /** + * Copy the target event's name to the clipboard so the RD can paste it into the type-to-confirm + * field (the friction gate stays; we only make grabbing the exact name easy). Shows brief + * "Copied" feedback, reverting after ~1.5s. Gracefully no-ops if the Clipboard API is missing + * (e.g. an insecure context) — the selectable box still lets the RD copy by hand. + */ + async function copyName() { + const name = deleteTarget?.name; + if (!name) return; + try { + await navigator.clipboard?.writeText(name); + } catch { + // No-op: clipboard unavailable/denied. The user-select:all box remains the manual fallback. + return; + } + if (copyTimer) clearTimeout(copyTimer); + nameCopied = true; + copyTimer = setTimeout(() => { + nameCopied = false; + copyTimer = undefined; + }, 1500); } async function confirmDelete() { @@ -393,7 +429,22 @@ registration, lap, result, and round. This cannot be undone and the data is unrecoverable.

    - + +
    + {deleteTarget.name} + +
    + , so the `user-select: +// all` styling is asserted against the box's rule in the source (it's load-bearing for the UX). +import SOURCE from '../src/screens/EventPicker.svelte?raw'; +import { makeTestSession } from './support.js'; + +const noop = () => {}; + +const EVENT: EventMeta = { + id: 'friday-series', + name: 'Friday Night Series', + created_at: 0, + persistent: true, + timers: [], + roster: [], + classes: [] +}; + +/** A picker whose open list resolves [EVENT], with no active event. */ +function renderPicker() { + const { session } = makeTestSession({ + noEnter: true, + listEventsImpl: vi.fn(async () => [EVENT]), + getActiveEventImpl: vi.fn(async () => ({ event: null })), + deleteEventImpl: vi.fn(async () => undefined) + }); + render(EventPicker, { session, onhome: noop }); +} + +/** Open the hard-delete dialog for EVENT and return its scope. */ +async function openDeleteDialog() { + renderPicker(); + const del = await screen.findByRole('button', { name: `Delete ${EVENT.name}` }); + await fireEvent.click(del); + const dialog = await screen.findByRole('dialog'); + return within(dialog); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('EventPicker — copyable event-name box in the delete dialog', () => { + it('renders the event name in a user-select:all box', async () => { + const dialog = await openDeleteDialog(); + const box = dialog.getByLabelText('Event name to copy'); + expect(box).toHaveTextContent(EVENT.name); + expect(box.tagName.toLowerCase()).toBe('code'); + // The box carries the `.copy-name-value` class whose rule sets `user-select: all` (a single + // click highlights the whole name). jsdom doesn't apply Svelte's scoped styles to + // getComputedStyle, so assert the rule is wired: the class is on the box, and its block in the + // component source declares user-select: all. + expect(box.classList.contains('copy-name-value')).toBe(true); + const rule = SOURCE.match(/\.copy-name-value\s*\{[^}]*\}/); + expect(rule, 'expected a .copy-name-value style rule').not.toBeNull(); + expect(rule![0].replace(/\s+/g, '')).toContain('user-select:all'); + }); + + it('copies the name to the clipboard and shows "Copied" feedback', async () => { + const writeText = vi.fn(async () => undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + const dialog = await openDeleteDialog(); + const copyBtn = dialog.getByRole('button', { name: `Copy event name “${EVENT.name}”` }); + expect(copyBtn).toHaveTextContent('Copy'); + + await fireEvent.click(copyBtn); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(EVENT.name); + await waitFor(() => expect(copyBtn).toHaveTextContent('Copied')); + }); + + it('keeps the type-to-confirm gate: the copy box does not enable delete on its own', async () => { + vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(async () => undefined) } }); + + const dialog = await openDeleteDialog(); + const confirm = dialog.getByRole('button', { name: 'Delete permanently' }); + expect(confirm).toBeDisabled(); + + // Copying alone must NOT enable the red button. + await fireEvent.click(dialog.getByRole('button', { name: /Copy event name/ })); + expect(confirm).toBeDisabled(); + + // Only typing the exact name enables it. + await fireEvent.input(dialog.getByRole('textbox', { name: 'Confirm event name' }), { + target: { value: EVENT.name } + }); + await waitFor(() => expect(confirm).toBeEnabled()); + }); + + it('gracefully no-ops when the clipboard API is unavailable', async () => { + vi.stubGlobal('navigator', {}); + + const dialog = await openDeleteDialog(); + const copyBtn = dialog.getByRole('button', { name: /Copy event name/ }); + // Must not throw, and stays in its default state (no "Copied"). + await fireEvent.click(copyBtn); + expect(copyBtn).toHaveTextContent('Copy'); + expect(copyBtn).not.toHaveTextContent('Copied'); + }); +}); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 4fa9f91..a784130 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -8,6 +8,9 @@ import type { ProtocolClient, ProtocolState, StateListener, + listEvents, + deleteEvent, + getActiveEvent, listTimers, createTimer, updateTimer, @@ -54,6 +57,12 @@ const PRACTICE: EventMeta = { /** The registry seams a screen test can override (all optional; defaults are inert). */ export interface TimerImpls { + /** The open event-list read — backs the EventPicker page tests. */ + listEventsImpl?: typeof listEvents; + /** The hard-delete write seam — backs the EventPicker delete-dialog tests. */ + deleteEventImpl?: typeof deleteEvent; + /** The active-event read (#91) — backs the EventPicker "Active" pill tests. */ + getActiveEventImpl?: typeof getActiveEvent; listTimersImpl?: typeof listTimers; createTimerImpl?: typeof createTimer; updateTimerImpl?: typeof updateTimer; @@ -142,6 +151,10 @@ export function makeTestSession( controlFactory: () => ({ baseUrl: 'http://d.local', sendCommand: sendSpy }), baseUrl: 'http://d.local', autoRestore: false, + // Event read/delete seams (EventPicker page): inert unless a test overrides them. + listEventsImpl: opts?.listEventsImpl, + deleteEventImpl: opts?.deleteEventImpl, + getActiveEventImpl: opts?.getActiveEventImpl, // Timer-registry seams (issue #73): inert unless a test overrides them. listTimersImpl: opts?.listTimersImpl, createTimerImpl: opts?.createTimerImpl, diff --git a/frontend/e2e/delete-event.spec.ts b/frontend/e2e/delete-event.spec.ts index ea9a8dd..7b9bf73 100644 --- a/frontend/e2e/delete-event.spec.ts +++ b/frontend/e2e/delete-event.spec.ts @@ -4,9 +4,10 @@ * from the list. * * The flow drives real clicks in headless chromium against the worker's real Director: open the - * Events picker, create a uniquely-named event, open its Delete affordance, and assert the red - * "Delete permanently" button stays disabled until the exact event name is re-typed. Then confirm - * and assert the event row disappears from the list (the delete really took effect server-side). + * Events picker, create a uniquely-named event, open its Delete affordance, copy the name from the + * copyable name box (Copy button → clipboard), and assert the red "Delete permanently" button stays + * disabled until the exact event name is re-typed. Then confirm and assert the event row disappears + * from the list (the delete really took effect server-side). * * Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack dump * (browser console, page errors, the Director's log). @@ -74,6 +75,18 @@ test('create an event, then permanently delete it through the hard-confirm dialo const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: /Delete event permanently/ })).toBeVisible(); + // The copyable name box shows the exact name; the Copy button writes it to the clipboard and + // flips to "Copied". Grant clipboard permission so navigator.clipboard.writeText resolves. + await expect(dialog.getByLabel('Event name to copy')).toHaveText(name); + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']) + .catch(() => {}); + const copyBtn = dialog.getByRole('button', { name: /Copy event name/ }); + await copyBtn.click(); + await expect(copyBtn).toHaveText(/Copied/); + const clip = await page.evaluate(() => navigator.clipboard.readText()).catch(() => ''); + // The red confirm button is DISABLED until the exact name is typed. const confirm = dialog.getByRole('button', { name: 'Delete permanently' }); await expect(confirm).toBeDisabled(); @@ -83,8 +96,10 @@ test('create an event, then permanently delete it through the hard-confirm dialo await field.fill('not the name'); await expect(confirm).toBeDisabled(); - // The exact name enables it. - await field.fill(name); + // Paste the copied name (falling back to the known name if clipboard read is unavailable), + // which is the exact name — that enables the gated red button. + await field.fill(clip || name); + await expect(field).toHaveValue(name); await expect(confirm).toBeEnabled(); // Confirm — the dialog closes and the event row is gone from the list. From 6acbbf700ee564dfa0ed31101fb89a49b38fe00e Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:14:29 +0000 Subject: [PATCH 146/362] fix(open-practice): live-state phase/clock from the real log + overlay laps only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-practice live state was a fully synthetic LiveRaceState built from the accumulator's own `Running` start plus a shadow-recorded transition list, served *in place of* the log fold. That synthetic phase/clock drifted from the real heat: - A Restart that lands the heat in `Staged` was never reflected, so the console kept rendering Unofficial/Final buttons (Finalize/Restart/Discard) and Restart then errored "illegal heat command Restart in state Staged". - Re-computing the synthetic slice reset the clock basis, producing the ~0.104s clock bump after the time limit fired. Make the overlay laps-only and authoritative on phase/clock from the real log: - open_practice.rs: drop the `transitions` shadow list and the `transition` method. The accumulator keeps heat + channels + passes; `merge_into(base)` splices its per-channel laps onto a log-authoritative LiveRaceState (guarded on the base's current_heat == the active heat), recomputing the running order. - ws.rs / app.rs: every fold/snapshot site now folds the *real log* for phase, clock and lineup, then calls `merge_into` for the laps. Phase/clock can no longer drift — they are always the log's (Restart -> Staged, time-limit -> Unofficial both reflected), while the non-logged laps still show. - source.rs: the kept `Finished` step needs no phase threading (the log already carries it); the true terminals/abort/restart clear the accumulator and wake `/stream` so it re-folds the now-overlay-free log state with no stale frame (wake-on-clear). - live_state.rs: `running_order` made pub(crate) for the merge. Tests: race_flow open-practice e2e drives Running -> (time limit) Unofficial -> Restart and asserts the *served* heat snapshot's phase follows the log exactly (Unofficial then Staged, never a stale Unofficial), laps held through Unofficial and cleared after Restart. open_practice.rs unit tests cover the merge (log phase + overlay laps), the Restart->Staged transition, the non-active-heat guard, and wake-on-clear. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/source.rs | 32 ++- crates/app/tests/race_flow.rs | 125 +++++++--- crates/server/src/app.rs | 29 +-- crates/server/src/live_state.rs | 2 +- crates/server/src/open_practice.rs | 379 +++++++++++++++++------------ crates/server/src/ws.rs | 76 +++--- 6 files changed, 384 insertions(+), 259 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 38da815..f14e08a 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -988,24 +988,22 @@ fn handle_transition( } // 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 and push the now-idle live state. 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`). + // 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`). // - // Open-practice overlay-phase fix: the kept `Finished` step must also move the overlay's - // phase. Thread the real transition into the accumulator so its synthetic slice reports - // `Unofficial` (not a hardcoded `Running`) — which freezes the console race clock at the - // practice duration while the per-channel laps stay visible. The true terminals below - // clear the accumulator outright, so they need no phase threading. - if state.open_practice().is_active(&heat) { - let woke = if matches!(transition, HeatTransition::Finished) { - state.open_practice().transition(&heat, transition) - } else { - state.open_practice().clear() - }; - if woke { - state.wake_streams(); - } + // 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 `Staged` 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(); } } // Staged is pre-Running: the heat isn't emitting yet, but it is the moment to **tune** the diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index bd4cfce..7bb6a8d 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -1272,56 +1272,105 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() "the time limit auto-ends the practice exactly once" ); - // (d) Open-practice overlay-phase fix — the clock-freeze precondition. The served/overlay live - // state (the same accumulator the snapshot/stream serve in place of the log fold for an active - // open-practice heat) must report **`Unofficial`** once the time limit closes the race — *not* - // the old hardcoded `Running` — so the console race clock freezes at the practice duration. The - // bridge threads the heat's real `Finished` transition into the accumulator, which keeps the - // per-channel laps (the accumulator is only cleared on a true terminal / abort / restart). + // (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 bridge processes the `Finished` it observes one poll after the log carries it, so wait for - // the overlay to settle on `Unofficial` rather than reading immediately. - let target = heat.clone(); + // 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 → Staged: the RD restarts the closed practice. The engine lands the heat in + // `Staged`; the bridge clears the accumulator (wake-on-clear). The served live state must then + // read **`Staged`** 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::Staged) + }) + .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); - loop { - let overlay = practice_overlay.open_practice().live_state(); - if overlay - .as_ref() - .is_some_and(|s| s.phase == gridfpv_server::snapshot::HeatPhase::Unofficial) - { - break; - } + while practice_overlay.open_practice().is_active(&heat) { assert!( std::time::Instant::now() < deadline, - "the open-practice overlay never reported Unofficial after the time limit (was {:?})", - overlay.map(|s| s.phase) + "the open-practice accumulator never cleared after Restart" ); - tokio::time::sleep(Duration::from_millis(50)).await; + tokio::time::sleep(Duration::from_millis(25)).await; } - let overlay = practice_overlay - .open_practice() - .live_state() - .expect("the open-practice overlay survives the Running → Unofficial step"); + let staged = served_live(&heat).await; assert_eq!( - overlay.current_heat.as_ref(), - Some(&target), - "the overlay still reports the practice heat" + staged.phase, + gridfpv_server::snapshot::HeatPhase::Staged, + "after Restart the served phase is the log's Staged — never a stale Unofficial" ); - assert_eq!( - overlay.phase, - gridfpv_server::snapshot::HeatPhase::Unofficial, - "the overlay reports the heat's real phase (Unofficial) so the console clock freezes" - ); - // The per-channel laps are still carried through Unofficial (the clear-on-stop kept them). assert!( - !overlay.progress.is_empty(), - "the per-channel laps stay visible through Unofficial" + staged.progress.iter().all(|p| p.laps_completed == 0), + "the per-channel laps are cleared after Restart" ); - assert!( - overlay.progress.iter().all(|p| p.pilot.is_none()), - "open-practice rows stay unbound (per channel)" + + // 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 → + // Staged 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 lands the heat in Staged" ); } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 9298332..0e80185 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1350,14 +1350,12 @@ async fn snapshot_event( ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; let (events, cursor) = state.read()?; - // Open-practice overlay (open-practice format, Slice 1): while an open-practice heat is active - // its per-channel laps are in memory (NOT logged), so the snapshot serves the accumulator's live - // state in place of the log fold — "snapshot first, then subscribe" stays correct (the `/stream` - // fold applies the same overlay), so a client renders the live per-channel laps immediately. - let body = state - .open_practice() - .live_state() - .unwrap_or_else(|| live_state(&events)); + // 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. + let body = state.open_practice().merge_into(live_state(&events)); Ok(Json(Snapshot { cursor, body: ProjectionBody::LiveRaceState(body), @@ -1455,14 +1453,13 @@ async fn snapshot_heat( let body = match query.projection { HeatProjection::Live => { - // Open-practice overlay (open-practice format, Slice 1): when this *is* the active - // open-practice heat, its laps live in the in-memory accumulator (NOT the log), so serve - // the accumulator's live state; otherwise fold the heat's log window as usual. - let open = state - .open_practice() - .live_state() - .filter(|s| s.current_heat.as_ref() == Some(&heat)); - ProjectionBody::LiveRaceState(open.unwrap_or_else(|| live_state(&heat_events))) + // 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(live_state(&heat_events)), + ) } HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( heat_events.iter().enumerate().map(|(i, e)| (i as u64, e)), diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 9227eec..eea76a9 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -254,7 +254,7 @@ fn on_deck(events: &[Event], current: &HeatId) -> Option { /// Rank active pilots into the provisional running order: most laps first, ties broken /// by the shorter last-lap time (a proxy for who is pacing ahead), then by competitor /// ref for a total, deterministic order. -fn running_order(progress: &[PilotProgress]) -> Vec { +pub(crate) fn running_order(progress: &[PilotProgress]) -> Vec { let mut order: Vec<&PilotProgress> = progress.iter().collect(); order.sort_by(|a, b| { b.laps_completed diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs index af08453..b214720 100644 --- a/crates/server/src/open_practice.rs +++ b/crates/server/src/open_practice.rs @@ -10,17 +10,26 @@ //! `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 — a fresh-value re-snapshot on the existing `/stream` +//! # Live delivery — the **real log's** phase/clock, overlaid with the per-channel laps //! -//! The change stream folds the [`LiveRaceState`] purely from the log; non-logged laps cannot drive -//! it through the fold. So the accumulator is a small **overlay** the live-state fold checks: while -//! an open-practice heat is active, the fold returns the accumulator's computed [`LiveRaceState`] -//! (per channel, `pilot: None`) **instead of** the log fold — and every accumulator mutation wakes -//! the same append-notify ([`AppState::appended`](crate::app::AppState)) that an `append` would, so a -//! parked stream re-folds and pushes a fresh-value envelope. This reuses the whole existing -//! snapshot and change-stream machinery (the [`LiveRaceState`]/[`PilotProgress`] shape, the -//! fresh-value envelope, the WS transport) with **no new channel and no new wire type** — the one -//! cost is this per-event overlay cell the live-state fold consults. +//! 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` landing the heat in +//! `Staged` 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) //! @@ -28,16 +37,17 @@ //! 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`. +//! `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, live_state}; +use crate::live_state::{LiveRaceState, PilotProgress, live_state}; -/// One open-practice heat's **in-memory** state: the active heat, its channel lineup, the -/// accumulated lap-gate passes (never logged), and the heat's **current loop phase**. +/// 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. @@ -47,25 +57,16 @@ struct ActivePractice { /// 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, - /// The heat-loop transitions this open-practice heat has gone through *after* `Running`, in - /// order — threaded in by the source bridge as it observes the heat's `HeatStateChanged` - /// (open-practice overlay-phase fix). Empty while the practice is live (`Running`); gains - /// `Finished` when the time limit (or a `ForceEnd`) closes the race, so the overlay reports - /// `Unofficial` and the console clock **freezes** — and any subsequent step (e.g. `Finalized`) - /// the heat reaches while its laps are still shown. Re-synthesized into the live fold so the - /// overlay's phase tracks the *real* heat phase, not a hardcoded `Running`. - transitions: Vec, } impl ActivePractice { - /// Synthesize the event slice the live-state fold consumes: the heat's `HeatScheduled` over the - /// active channels, a `Running` transition, the **actual subsequent transitions** the heat has - /// reached (e.g. `Finished` once its time limit fires, so the live phase reads `Unofficial`), - /// then the accumulated passes. Reusing [`live_state`] over this slice gives per-channel laps - /// with `pilot: None` *and* the heat's real current phase for free — the same fold the logged - /// path uses, no second lap definition and no hardcoded phase. + /// 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 + self.transitions.len()); + let mut events = Vec::with_capacity(self.passes.len() + 2); events.push(Event::HeatScheduled { heat: self.heat.clone(), lineup: self.channels.clone(), @@ -73,29 +74,20 @@ impl ActivePractice { round: None, frequencies: Vec::new(), }); - // The race is live from `Running`; the bridge threads in any later transition (e.g. the - // time-limit `Finished` → `Unofficial`) so the overlay phase — and so the console clock — - // follows the heat. The passes follow the transitions; the lap fold is order-independent - // over them, and `live_state` resolves the phase from the transition sequence. + // 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.transitions - .iter() - .copied() - .map(|transition| Event::HeatStateChanged { - heat: self.heat.clone(), - transition, - }), - ); events.extend(self.passes.iter().cloned().map(Event::Pass)); events } - /// The live race-state for this open-practice heat: per-channel laps from the accumulated - /// passes, each channel an unbound competitor (`pilot: None`). + /// 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()) } @@ -105,8 +97,9 @@ impl ActivePractice { /// /// 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 reads -/// its computed live state). One per event, alongside the event's [`AppState`](crate::app::AppState). +/// 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>>, @@ -125,31 +118,9 @@ impl OpenPracticeLive { heat, channels, passes: Vec::new(), - transitions: Vec::new(), }); } - /// Thread one heat-loop `transition` for the active open-practice `heat` into the overlay - /// (open-practice overlay-phase fix). The source bridge calls this as it observes the heat's - /// `HeatStateChanged`, so the overlay reports the heat's **real** current phase: `Running` while - /// the practice is live, then `Unofficial` once the time limit (or a `ForceEnd`) closes it — - /// which freezes the console race clock at the practice duration — while the per-channel laps - /// stay visible. The accumulator is **not** cleared here (the clear-on-terminal path drops it); - /// `Running` is implicit (the base of every synthetic slice) so re-recording it is a no-op. - /// - /// A no-op when `heat` is not the active open-practice heat. Returns whether the overlay phase - /// actually changed, so the caller wakes `/stream` only when the live state moved. - pub fn transition(&self, heat: &HeatId, transition: HeatTransition) -> bool { - let mut guard = self.write(); - match guard.as_mut() { - Some(active) if &active.heat == heat && transition != HeatTransition::Running => { - active.transitions.push(transition); - true - } - _ => false, - } - } - /// 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 @@ -167,9 +138,9 @@ impl OpenPracticeLive { } /// **Clear** the accumulator — drop all in-memory open-practice laps. Called when the - /// open-practice heat leaves `Running` (a terminal / abort transition) or a new heat/round takes - /// over. Returns whether anything was actually cleared (so the caller wakes `/stream` to push the - /// now-idle live state only when needed). + /// 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() { @@ -185,8 +156,59 @@ impl OpenPracticeLive { self.read().as_ref().map(|a| &a.heat) == Some(heat) } - /// The current open-practice [`LiveRaceState`] overlay, or `None` when no open-practice heat is - /// active (the `/stream` fold then uses the normal log fold). Per channel, `pilot: None`. + /// 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 → Staged` 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 `Staged`) 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) } @@ -207,8 +229,9 @@ impl OpenPracticeLive { #[cfg(test)] mod tests { use super::*; + use crate::live_state::live_state; use crate::snapshot::HeatPhase; - use gridfpv_events::{AdapterId, GateIndex, SourceTime}; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, SourceTime}; fn chan(i: usize) -> CompetitorRef { CompetitorRef(format!("node-{i}")) @@ -225,10 +248,35 @@ mod tests { } } + /// 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(), + }]; + 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)), @@ -237,11 +285,23 @@ mod tests { } #[test] - fn per_channel_laps_are_derived_with_no_pilot_bound() { + 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()); - live.begin(heat.clone(), vec![chan(0), chan(2)]); - assert!(live.is_active(&heat)); + 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))); @@ -250,18 +310,20 @@ mod tests { assert!(live.record(pass(2, 4_500_000, 1))); assert!(live.record(pass(0, 6_500_000, 2))); - let state = live - .live_state() - .expect("an active open-practice live state"); - assert_eq!(state.current_heat, Some(heat)); - // While the practice is live (no transition threaded past `Running`), the overlay phase is - // `Running` — so the console race clock ticks. - assert_eq!(state.phase, HeatPhase::Running); - // Two channels, each a row; neither bound to a pilot (open practice is per channel). - assert_eq!(state.progress.len(), 2); - assert!(state.progress.iter().all(|p| p.pilot.is_none())); - - let n0 = state + // 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)) @@ -269,95 +331,103 @@ mod tests { assert_eq!(n0.laps_completed, 2); assert_eq!(n0.last_lap_micros, Some(2_500_000)); - let n2 = state + let n2 = merged .progress .iter() .find(|p| p.competitor == chan(2)) .unwrap(); assert_eq!(n2.laps_completed, 1); - } - #[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)); + // Running order reflects the spliced laps (node-0 leads with 2 laps). + assert_eq!(merged.running_order, vec![chan(0), chan(2)]); } #[test] - fn finished_transition_reports_unofficial_with_laps_intact() { - // Open-practice overlay-phase fix: when the heat's time limit fires `Running → Unofficial` - // (a `Finished` transition), the overlay must report **`Unofficial`** (so the console clock - // freezes) while the per-channel laps stay visible — not the old hardcoded `Running`. + fn merge_follows_the_log_phase_through_unofficial_then_staged_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 + // `Staged` — so the merge yields `Staged` with NO stale `Unofficial`. let live = OpenPracticeLive::new(); let heat = HeatId("open-practice".into()); - live.begin(heat.clone(), vec![chan(0)]); + 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) - // While racing the overlay is `Running`. - assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); - - // The time limit closes the race: thread the real `Finished` transition in. - assert!( - live.transition(&heat, HeatTransition::Finished), - "threading Finished into the active open-practice heat reports a change" - ); - - let state = live - .live_state() - .expect("the overlay survives Running → Unofficial"); + // 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!( - state.phase, + unofficial.phase, HeatPhase::Unofficial, - "the overlay reports Unofficial once the heat finishes, so the clock freezes" + "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 `Staged`). The merge of the now-empty accumulator onto the `Staged` base is + // the bare log state: phase `Staged`, no stale `Unofficial`, laps cleared. + assert!(live.clear(), "restart clears the accumulator"); + let staged = live.merge_into(log_base( + &heat, + &channels, + &[ + HeatTransition::Running, + HeatTransition::Finished, + HeatTransition::Restarted, + ], + )); + assert_eq!( + staged.phase, + HeatPhase::Staged, + "after Restart the served phase is the log's Staged — never a stale Unofficial" ); - // The laps are NOT cleared by the Running → Unofficial step. - let n0 = state - .progress - .iter() - .find(|p| p.competitor == chan(0)) - .unwrap(); - assert_eq!(n0.laps_completed, 1); - assert_eq!(n0.last_lap_micros, Some(3_000_000)); - - // A subsequent `Finalized` step folds to `Final`, still carrying the laps. - assert!(live.transition(&heat, HeatTransition::Finalized)); - let state = live.live_state().unwrap(); - assert_eq!(state.phase, HeatPhase::Final); assert_eq!( - state - .progress - .iter() - .find(|p| p.competitor == chan(0)) - .unwrap() - .laps_completed, - 1 + staged.progress[0].laps_completed, 0, + "the per-channel laps are cleared after Restart" ); } #[test] - fn transition_is_a_no_op_for_a_stale_or_idle_heat() { + 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(); - // No active heat → no-op. - assert!(!live.transition(&HeatId("h1".into()), HeatTransition::Finished)); + 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"); + } - live.begin(HeatId("h1".into()), vec![chan(0)]); - // A transition for a *different* heat is ignored (the overlay phase doesn't move). - assert!(!live.transition(&HeatId("h2".into()), HeatTransition::Finished)); - assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); - // Re-recording `Running` is a no-op (it is the implicit base of the slice). - assert!(!live.transition(&HeatId("h1".into()), HeatTransition::Running)); - assert_eq!(live.live_state().unwrap().phase, HeatPhase::Running); + #[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] @@ -368,6 +438,7 @@ mod tests { // 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/ws.rs b/crates/server/src/ws.rs index 8d3b563..cb132d5 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -165,31 +165,29 @@ impl ScopeProjection { /// 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 live state** (open-practice format, Slice 1) when an - /// open-practice heat is active, else `None`. An open-practice heat's laps are accumulated in - /// memory (NOT logged), so the log fold can't see them; while the overlay is present, the - /// live-state scopes return it instead of the log fold — for a Heat scope only when it is *that* - /// heat. The lap-list (pilot) scope is unaffected (open practice is per channel, not per pilot). + /// `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, events: &[Event], - overlay: Option<&crate::live_state::LiveRaceState>, + overlay: Option<&crate::open_practice::OpenPracticeLive>, ) -> Option { match scope { Scope::Event { .. } | Scope::Class { .. } => { - // While an open-practice heat is active, the (non-logged) per-channel live state - // replaces the log fold for the whole-event scopes. - let live = overlay.cloned().unwrap_or_else(|| live_state(events)); + // 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). + let mut live = live_state(events); + if let Some(op) = overlay { + live = op.merge_into(live); + } Some(ProjectionBody::LiveRaceState(live)) } Scope::Heat { heat } => { - // An open-practice heat's live state lives in the overlay, not the log — so serve it - // when this Heat scope addresses the active open-practice heat. - if let Some(open) = overlay { - if open.current_heat.as_ref() == Some(heat) { - return Some(ProjectionBody::LiveRaceState(open.clone())); - } - } // 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 @@ -198,8 +196,16 @@ impl ScopeProjection { if !scheduled { return None; } + // The heat's phase/clock are its real log window; splice the open-practice laps onto + // it when this Heat scope addresses the active open-practice heat. let window = heat_window(events, heat); - Some(ProjectionBody::LiveRaceState(live_state(&window))) + let mut live = live_state(&window); + 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 = @@ -329,12 +335,12 @@ async fn run_stream(mut socket: WebSocket, state: AppState) { return; } }; - // The event's open-practice overlay (open-practice format, Slice 1): the per-channel, - // in-memory (NOT logged) live state when an open-practice heat is active, else `None`. The - // fold serves it in place of the log fold for the live-state scopes so the non-logged laps - // drive the stream. `wake_streams` after a pass / clear is what re-enters this loop. - let overlay = state.open_practice().live_state(); - for message in engine.advance(&events, overlay.as_ref()) { + // 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 } @@ -393,15 +399,17 @@ impl Engine { /// 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 live state (open-practice format, Slice 1) when an - /// open-practice heat is active. The per-offset walk folds the **pure log** (overlay-free) so - /// logged changes stay gap-free; then, when the overlay is active, a final overlay-applied 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`. + /// `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. fn advance( &mut self, events: &[Event], - overlay: Option<&crate::live_state::LiveRaceState>, + overlay: Option<&crate::open_practice::OpenPracticeLive>, ) -> Vec { let mut out = Vec::new(); let len = events.len() as u64; @@ -430,10 +438,12 @@ impl Engine { } // The open-practice live re-snapshot (open-practice format, Slice 1): with an active - // overlay, fold the current prefix *with* it and emit when it differs from the last value — - // the non-logged per-channel laps reach the stream as a fresh-value `LiveRaceState`. When the - // overlay clears, the next pure-log fold (above, on the next wake) restores the logged value. - if overlay.is_some() { + // 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()))); From 355eb26a0eee0f1f06aa68300a6c3996ccf827fa Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:20:08 +0000 Subject: [PATCH 147/362] fix: don't play start tone on navigating to a running heat (late-join) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start tone is a race-go cue for the RD *watching* a heat go live. A recent robustness fix played it on the first observation of `Running` for a heat (per-heat fired flag, to catch fast/batched Armed→Running). Side effect: navigating to the Live page of an *already-running* heat (a late join) made that first observation be `Running`, so the tone buzzed on every navigation. Fire only on an OBSERVED transition into `Running`: track per-heat whether a pre-Running phase (Scheduled/Staged/Armed) was seen first, and play on `Running` only if so. This still fires on a genuine race-go you watched (including a fast Armed→Running, since Staged/Armed was seen first) and on a fresh heat you staged — but not on landing on an in-progress heat. The per-heat tracking resets on a heat change. Also remove the inline "Enable / Test tone" toolbar button and its now- unused `enable()`/`locked` plumbing in startTone.ts. The AudioContext resume-on-gesture (called by the control buttons + mute toggle) stays, so the real start tone still unlocks and plays. The mute toggle stays. Tests: invert the "Running first" unit test to assert no tone on a late join; add a transition-fires test; rework the e2e to assert landing on a Running heat builds no oscillator while a watched race-go does; drop the test-button tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- frontend/apps/rd-console/src/lib/startTone.ts | 36 +---- .../src/screens/LiveRaceControl.svelte | 140 ++++------------- .../rd-console/tests/LiveRaceControl.test.ts | 67 +++++--- .../apps/rd-console/tests/startTone.test.ts | 102 ------------- frontend/e2e/tone-and-confirm.spec.ts | 144 +++++++++++------- 5 files changed, 171 insertions(+), 318 deletions(-) diff --git a/frontend/apps/rd-console/src/lib/startTone.ts b/frontend/apps/rd-console/src/lib/startTone.ts index dfaf7d1..0b19de7 100644 --- a/frontend/apps/rd-console/src/lib/startTone.ts +++ b/frontend/apps/rd-console/src/lib/startTone.ts @@ -15,9 +15,11 @@ * * ── Autoplay policy ────────────────────────────────────────────────────────────────────────── * Browsers suspend a freshly-created `AudioContext` until a user gesture. The player lazily creates - * the context on first use and {@link StartTonePlayer.resume}s it; the console also calls `resume()` - * from the first RD click (Stage/Start) so the context is unlocked well before race-go. If the - * context can't be created (no Web Audio) every call is a silent no-op — the tone never blocks the UI. + * the context on first use and {@link StartTonePlayer.resume}s it; the console calls `resume()` from + * the RD's control clicks (Stage/Start) and the mute toggle so the context is unlocked well before + * race-go. {@link StartTonePlayer.play} also resumes a still-suspended context before scheduling the + * note (the suspended-clock fix). If the context can't be created (no Web Audio) every call is a + * silent no-op — the tone never blocks the UI. * * ── Testability ────────────────────────────────────────────────────────────────────────────── * The `AudioContext` constructor is **injected** ({@link StartTonePlayerOptions.audioContextFactory}), @@ -127,18 +129,6 @@ export class StartTonePlayer { return this.#factory !== undefined; } - /** - * Whether the audio is currently **locked** — Web Audio is available but the context has not yet - * reached the `running` state (the browser autoplay policy still has it suspended, or it hasn't - * been created yet). Drives the toolbar's "audio enabled / locked" indicator so the RD can see at - * a glance whether a race-go tone will actually sound, and prime it before the race if not. When - * Web Audio is unavailable this reads `false` (nothing to unlock — the toolbar shows "no audio"). - */ - get locked(): boolean { - if (!this.#factory) return false; - return this.#ctx?.state !== 'running'; - } - /** Set the mute preference and persist it. */ setMuted(muted: boolean): void { this.#muted = muted; @@ -178,22 +168,6 @@ export class StartTonePlayer { } } - /** - * The explicit **"Enable sound / Test tone"** affordance: a definite user gesture that **unlocks - * the context and plays one confirmation beep** so the RD can prime audio and *hear* that it works - * before the race — the reliable escape hatch regardless of autoplay quirks. Unlike {@link play}, - * the confirmation beep is **not** gated by mute (the RD asked to test it; muting only suppresses - * the automatic race-go tone). Resolves to `true` when the context is `running` afterwards (so the - * caller can refresh its enabled/locked indicator). Never throws. - */ - async enable(cue?: ToneCue): Promise { - await this.resume(); - const ctx = this.#context(); - if (!ctx) return false; - if (ctx.state === 'running') this.#emit(ctx, cue); - return ctx.state === 'running'; - } - /** * Play the start tone now. Muted ⇒ no-op; no Web Audio ⇒ no-op. The cue's `hz`/`ms` fall back to * the {@link DEFAULT_HZ}/{@link DEFAULT_MS} defaults. Never throws. diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index a4f41b8..543d9b7 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -209,33 +209,47 @@ () => stagingSecs ); - // ── Start tone synced to race-go (heat-lifecycle Slice 3; robustness fix) ──────────────────── + // ── Start tone synced to race-go (heat-lifecycle Slice 3; robustness + late-join fix) ────────── // A short Web-Audio beep the moment a heat goes live (race-go). The runtime logs // `HeatStarting { delay_ms }` then auto-appends the Running transition after the (hidden) random // hold; the console plays the tone when the *live phase* turns Running. // - // ── Why a per-heat "fired" flag, not the Armed → Running *edge* (the missed-tone fix) ───────── - // The original trigger fired only on the exact `prevPhase === 'Armed' && phase === 'Running'` - // transition. That edge is unreliable: an open-practice heat (or any heat with no start delay) - // can go Armed → Running near-instantly, and live-state snapshots batch — so the effect can first - // observe the heat *already* Running (prevPhase still Staged/Scheduled, the Armed snapshot never - // landed), and the tone never fires. Instead we play on the **first observation of `Running` for - // a given heat**, from any prior phase, tracked by a per-heat flag that resets on a heat change. - // A repeated Running snapshot (progress update) for the same heat is ignored, so it still fires - // exactly once per heat — robust to fast/batched transitions and late joins alike. + // ── Fire on an OBSERVED transition into Running — not on a late join ────────────────────────── + // The tone is a race-go cue for the RD *watching* the heat go live. It must fire when the heat + // crosses **into** `Running` from a pre-Running phase the console actually saw this mount — i.e. + // a genuine race-go (Stage → Start → … → Running) or a fast/batched Armed → Running (we'd still + // have seen Staged/Armed first). It must **not** fire when the RD merely **navigates to the Live + // page of an already-running heat** (a late join): there the first phase the console observes for + // that heat *is* `Running`, with no prior pre-Running phase seen — an unwanted buzz on every + // navigation. So we only fire on `Running` if a pre-Running phase (`Scheduled`/`Staged`/`Armed`) + // was observed for this heat first; a heat seen Running as its first phase is suppressed. + // + // Per heat we track two things, both reset on a heat change: whether a pre-Running phase was seen + // (`tonePreRunningForHeat`), and whether the tone already fired (`toneFiredForHeat`, so repeated + // Running snapshots / progress updates don't re-fire). The next heat resets and fires its own. const tone = new StartTonePlayer(); $effect(() => () => tone.dispose()); let toneFiredForHeat = $state(undefined); + let tonePreRunningForHeat = $state(undefined); $effect(() => { const p = phase; const h = heat; - if (p === 'Running' && h !== undefined && toneFiredForHeat !== h) { + if (h === undefined) return; + if (p !== 'Running') { + // A pre-Running phase for this heat (or a fold back out of Running): remember that we observed + // a non-Running phase first, and (on a heat change) clear the fired flag so the next heat that + // enters Running fires its own tone. We only *arm* on an actual pre-Running phase so a heat + // that started Running then folded back to Unofficial/Final doesn't arm a spurious tone. + if (p === 'Scheduled' || p === 'Staged' || p === 'Armed') tonePreRunningForHeat = h; + if (toneFiredForHeat !== h) toneFiredForHeat = undefined; + return; + } + // p === 'Running'. Fire once — but only if a pre-Running phase for THIS heat was observed first + // (a genuine race-go we watched), not when Running is the first phase seen (a late join / page + // load onto an in-progress heat). + if (toneFiredForHeat !== h && tonePreRunningForHeat === h) { toneFiredForHeat = h; tone.play(toneCue); - } else if (p !== 'Running' && toneFiredForHeat !== undefined && h !== toneFiredForHeat) { - // Heat changed (or there's no heat) while not Running — clear the flag so the *next* heat that - // enters Running fires its own tone (e.g. a fresh practice run, or the next scheduled heat). - toneFiredForHeat = undefined; } }); let muted = $state(tone.muted); @@ -246,26 +260,6 @@ muted = tone.toggleMuted(); } - // ── Enable sound / Test tone (robustness fix) ──────────────────────────────────────────────── - // The reliable escape hatch for the browser autoplay policy: an explicit, visible control whose - // click is a definite user gesture that unlocks the AudioContext AND plays one confirmation beep - // — so the RD can prime audio and *hear* that the race-go tone will actually sound. The indicator - // shows whether audio is enabled (running) or still locked. `audioLocked` is refreshed after the - // resume settles (and on mount) so the label tracks the real context state. - let audioLocked = $state(tone.locked); - let testing = $state(false); - async function enableSound() { - if (testing) return; - testing = true; - try { - // Plays a confirmation beep regardless of mute (the RD explicitly asked to test it). - await tone.enable(toneCue); - } finally { - audioLocked = tone.locked; - testing = false; - } - } - // A live, provisional leaderboard from the running order + per-pilot progress, so the // RD sees standings before the heat is scored. Built into a `HeatResult` so we reuse // the shared `Leaderboard` component (laps + last-lap-time as the metric). @@ -341,27 +335,6 @@ {/if}
    - - -
    { vi.unstubAllGlobals(); }); -describe('EventPicker — copyable event-name box in the delete dialog', () => { +describe('EventPicker — selectable event-name box in the delete dialog', () => { it('renders the event name in a user-select:all box', async () => { const dialog = await openDeleteDialog(); const box = dialog.getByLabelText('Event name to copy'); @@ -71,30 +71,20 @@ describe('EventPicker — copyable event-name box in the delete dialog', () => { expect(rule![0].replace(/\s+/g, '')).toContain('user-select:all'); }); - it('copies the name to the clipboard and shows "Copied" feedback', async () => { - const writeText = vi.fn(async () => undefined); - vi.stubGlobal('navigator', { clipboard: { writeText } }); - + it('has no copy button (Clipboard API needs a secure context, unavailable over plain HTTP)', async () => { const dialog = await openDeleteDialog(); - const copyBtn = dialog.getByRole('button', { name: `Copy event name “${EVENT.name}”` }); - expect(copyBtn).toHaveTextContent('Copy'); - - await fireEvent.click(copyBtn); - - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText).toHaveBeenCalledWith(EVENT.name); - await waitFor(() => expect(copyBtn).toHaveTextContent('Copied')); + expect(dialog.queryByRole('button', { name: /Copy event name/ })).toBeNull(); }); - it('keeps the type-to-confirm gate: the copy box does not enable delete on its own', async () => { - vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(async () => undefined) } }); - + it('keeps the type-to-confirm gate: only typing the exact name enables delete', async () => { const dialog = await openDeleteDialog(); const confirm = dialog.getByRole('button', { name: 'Delete permanently' }); expect(confirm).toBeDisabled(); - // Copying alone must NOT enable the red button. - await fireEvent.click(dialog.getByRole('button', { name: /Copy event name/ })); + // A wrong name keeps it disabled. + await fireEvent.input(dialog.getByRole('textbox', { name: 'Confirm event name' }), { + target: { value: 'not the name' } + }); expect(confirm).toBeDisabled(); // Only typing the exact name enables it. @@ -103,15 +93,4 @@ describe('EventPicker — copyable event-name box in the delete dialog', () => { }); await waitFor(() => expect(confirm).toBeEnabled()); }); - - it('gracefully no-ops when the clipboard API is unavailable', async () => { - vi.stubGlobal('navigator', {}); - - const dialog = await openDeleteDialog(); - const copyBtn = dialog.getByRole('button', { name: /Copy event name/ }); - // Must not throw, and stays in its default state (no "Copied"). - await fireEvent.click(copyBtn); - expect(copyBtn).toHaveTextContent('Copy'); - expect(copyBtn).not.toHaveTextContent('Copied'); - }); }); diff --git a/frontend/e2e/delete-event.spec.ts b/frontend/e2e/delete-event.spec.ts index 7b9bf73..5d23939 100644 --- a/frontend/e2e/delete-event.spec.ts +++ b/frontend/e2e/delete-event.spec.ts @@ -4,10 +4,14 @@ * from the list. * * The flow drives real clicks in headless chromium against the worker's real Director: open the - * Events picker, create a uniquely-named event, open its Delete affordance, copy the name from the - * copyable name box (Copy button → clipboard), and assert the red "Delete permanently" button stays - * disabled until the exact event name is re-typed. Then confirm and assert the event row disappears - * from the list (the delete really took effect server-side). + * Events picker, create a uniquely-named event, open its Delete affordance, and assert the red + * "Delete permanently" button stays disabled until the exact event name is re-typed into the + * confirm field. Then confirm and assert the event row disappears from the list (the delete really + * took effect server-side). + * + * The dialog shows the exact name in a selectable box (no copy button — the Clipboard API needs a + * secure context and isn't available over the plain-HTTP Director). The RD selects/types it by hand; + * the spec fills the confirm field directly. * * Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack dump * (browser console, page errors, the Director's log). @@ -75,17 +79,10 @@ test('create an event, then permanently delete it through the hard-confirm dialo const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: /Delete event permanently/ })).toBeVisible(); - // The copyable name box shows the exact name; the Copy button writes it to the clipboard and - // flips to "Copied". Grant clipboard permission so navigator.clipboard.writeText resolves. + // The selectable name box shows the exact name (no copy button — the Clipboard API needs a secure + // context and isn't available over the plain-HTTP Director; the RD selects/types it by hand). await expect(dialog.getByLabel('Event name to copy')).toHaveText(name); - await page - .context() - .grantPermissions(['clipboard-read', 'clipboard-write']) - .catch(() => {}); - const copyBtn = dialog.getByRole('button', { name: /Copy event name/ }); - await copyBtn.click(); - await expect(copyBtn).toHaveText(/Copied/); - const clip = await page.evaluate(() => navigator.clipboard.readText()).catch(() => ''); + await expect(dialog.getByRole('button', { name: /Copy event name/ })).toHaveCount(0); // The red confirm button is DISABLED until the exact name is typed. const confirm = dialog.getByRole('button', { name: 'Delete permanently' }); @@ -96,9 +93,8 @@ test('create an event, then permanently delete it through the hard-confirm dialo await field.fill('not the name'); await expect(confirm).toBeDisabled(); - // Paste the copied name (falling back to the known name if clipboard read is unavailable), - // which is the exact name — that enables the gated red button. - await field.fill(clip || name); + // Typing the exact name into the confirm field enables the gated red button. + await field.fill(name); await expect(field).toHaveValue(name); await expect(confirm).toBeEnabled(); diff --git a/frontend/e2e/screenshots/delete-event-no-copy.png b/frontend/e2e/screenshots/delete-event-no-copy.png new file mode 100644 index 0000000000000000000000000000000000000000..91d3b515904bad96f423df50c0e5806d8c081453 GIT binary patch literal 33254 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 r?++h7yuGd97Hcb;`5-1FEm+R4>-%2-6V({& literal 0 HcmV?d00001 From 4c9f75319ff9f7bce572d27f7de88881466a5bba Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:46:26 +0000 Subject: [PATCH 149/362] fix(engine): Abort always resets the heat to Scheduled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heat FSM's Abort off-ramp previously backed up a single state: Staged → Scheduled, but Armed/Running → Staged. The RD wants Abort to fully reset an abortable heat (Staged/Armed/Running) to Scheduled so it gets re-Staged explicitly. Restart is unchanged (still → Staged — it re-stages immediately). `next_state` now lands every Aborted transition in Scheduled; legality of Abort (which states it is allowed from) is unchanged. Updated the module diagram, the command/transition doc comments, and the engine tests (abort-target + abort-and-re-run fold assertions). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/heat.rs | 51 ++++++++++++++++++++------------------- crates/events/src/lib.rs | 2 +- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/heat.rs b/crates/engine/src/heat.rs index 895c85f..d6b9726 100644 --- a/crates/engine/src/heat.rs +++ b/crates/engine/src/heat.rs @@ -11,8 +11,9 @@ //! Final --> Unofficial : revert //! Final --> [*] : advance //! Staged --> Scheduled : abort -//! Armed --> Staged : abort -//! Running --> Staged : abort / restart +//! Armed --> Scheduled : abort +//! Running --> Scheduled : abort +//! Running --> Staged : restart //! Armed --> Staged : restart //! Unofficial --> Staged : restart //! Final --> Scheduled : discard & re-run @@ -118,8 +119,8 @@ pub enum HeatCommand { Advance, /// Re-open a finalized result for correction (Final → Unofficial). Revert, - /// Abandon before finalizing — the target depends on the from-state - /// (Staged → Scheduled, Armed → Staged, Running → Staged). + /// 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 committed heat from staging (Armed/Running/Unofficial → Staged). Restart, @@ -172,8 +173,8 @@ pub fn apply(state: HeatState, command: HeatCommand) -> Result HeatTransition::Running, (S::Running, C::ForceEnd) => HeatTransition::Finished, - // Off-ramps. Abort is legal from Staged/Armed/Running (it backs up a - // state); the landing state is resolved by `next_state`. + // 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 to any committed heat short of finalized (Armed/Running/ // Unofficial), back to staging for a clean re-run. @@ -191,18 +192,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; @@ -217,11 +220,9 @@ pub fn next_state(from: HeatState, transition: HeatTransition) -> HeatState { T::Advanced => S::Final, // Revert re-opens a finalized result back to Unofficial for correction. T::Reverted => S::Unofficial, - // Abort backs up one state: Staged → Scheduled, Armed/Running → Staged. - T::Aborted => match from { - S::Staged => S::Scheduled, - _ => S::Staged, - }, + // Abort always resets the heat to Scheduled (from any abortable state), so the + // RD re-Stages it. + T::Aborted => S::Scheduled, T::Restarted => S::Staged, T::Discarded => S::Scheduled, } @@ -505,15 +506,14 @@ mod tests { } #[test] - fn abort_target_depends_on_the_from_state() { + fn abort_always_resets_to_scheduled() { use HeatState as S; use HeatTransition as T; - // Staged → Scheduled + // 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] @@ -606,13 +606,14 @@ mod tests { #[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), ]; @@ -669,7 +670,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] diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 69d79ad..5511d17 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -229,7 +229,7 @@ pub enum HeatTransition { Advanced, /// A finalized result re-opened for correction (Final → Unofficial). Reverted, - /// Abandoned before finalizing (Staged/Armed/Running → back a state). + /// Abandoned before finalizing (Staged/Armed/Running → Scheduled, so the RD re-Stages). Aborted, /// A committed heat restarted from staging. Restarted, From 7c56cb221727c1c833512a062a1c29b3e714d8b8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:46:33 +0000 Subject: [PATCH 150/362] fix(rd-console): name the open-practice heat "Open Practice Heat" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-practice round auto-creates a single heat whose lineup is the active channels; the Heats list showed its generated id. Heats carry no backend label/name (neither HeatScheduled nor HeatSummary has one), so derive the display name in the Heats UI: an open-practice round's heat reads "Open Practice Heat", every other round shows the heat's own id. Adds a unit test (open-practice round → heat renders "Open Practice Heat", not its id) and an e2e assertion in open-practice.spec.ts. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventRounds.svelte | 11 +++++- .../apps/rd-console/tests/EventRounds.test.ts | 35 +++++++++++++++++++ frontend/e2e/open-practice.spec.ts | 2 ++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 58a745d..dc523e0 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -187,6 +187,15 @@ // controls for it and shows the practice heat as ready to Start. const isOpenPracticeRound = (round: RoundDef): boolean => round.format === OPEN_PRACTICE; + // The display name for a heat in the Heats list. An open-practice round auto-creates a single + // heat (its lineup = the active channels); it reads better as "Open Practice Heat" than the + // generated id. Heats carry no backend label, so this is derived from the round's format — + // every other round shows the heat's own id. + const OPEN_PRACTICE_HEAT_NAME = 'Open Practice Heat'; + function heatDisplayName(round: RoundDef, h: HeatSummary): string { + return isOpenPracticeRound(round) ? OPEN_PRACTICE_HEAT_NAME : h.heat; + } + // A heat's per-pilot channel assignment, resolved to a band+channel label (race redesign Slice // 4b). `HeatScheduled.frequencies` pairs each ref with a raw MHz; map ref → label so the lineup // can show it. A sim/free-text heat carries no frequencies, so a ref resolves to `undefined` ("—"). @@ -1318,7 +1327,7 @@ -
  • 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. +

    Needs further discussion — signal-level marshaling & the adapter boundary. @@ -206,20 +299,30 @@

    5. Advancement & scheduling

    the log (§6).

    - Rounds are event-level and class-tagged (planned). The engine's rounds-and-heats - are promoted to the event level and each round 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 existing - quali→bracket logic). The generator interface (§3) and advancement above are unchanged — the - redesign wires them per-event and tags the log entries with class/round. + 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

    @@ -227,20 +330,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 From 7158d19f5b3c94d00025054562c88e31d535f187 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 23 Jun 2026 01:10:24 +0000 Subject: [PATCH 158/362] docs: reconcile event-pipeline + timer/channel docs to the redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the internal design docs to match the as-built (v0.4) race-running redesign for the event-pipeline + timer/channel area: - event-pipeline: add an as-built note for the staged event workspace (Classes+Roster / Rounds+Heats / Live / Marshaling / Results stage-pages, Next-only auto-saving wizard, phases-as-rounds). - pipeline-practice: rewrite to the single `open_practice` round — one auto-created open heat over chosen active channels (node seats, not pilots), per-channel in-memory board cleared on round change, optional time limit; drop the unbuilt rolling/scheduled/free-fly modes + shared-queue engine. - pipeline-qualifying: qualifying is a `timed_qual`/`round_robin` round targeting one class, "Rounds" param = heats per pilot (default 3), channel-balanced static heats, FromRanking top-N seeds the bracket; flag the metric-vs-win-condition + "Heats per pilot" label discrepancy. - pipeline-mains: as-built Rounds form (single-select class, win conditions, seeding, channel mode, staging 5:00 / grace 30s / start delays in seconds); win-condition options; node_count vs available_channels. - pipeline-setup: channels != nodes (pool can exceed node_count), per-pilot channel from primary timer, wizard now built; Timer = Mock | RotorHazard{url}. - timer-adapters: as-built timer/connection lifecycle (Mock vs RotorHazard{url}, RH connects when selected for the active event behind --features live, stage_race); harness specifics (cruwaller/rotorhazard, 8 mock nodes, port 5000, simulate_lap over Socket.IO). - new channel-model.html deep dive: channels != nodes, standard FPV catalog, Fixed/Flexible capability, static vs per-heat assignment, race-time tuning. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/channel-model.html | 182 ++++++++++++++++++++++++++++++++++ docs/event-pipeline.html | 16 +++ docs/pipeline-mains.html | 29 +++++- docs/pipeline-practice.html | 139 +++++++++++++++----------- docs/pipeline-qualifying.html | 151 +++++++++++++++++----------- docs/pipeline-setup.html | 41 +++++--- docs/timer-adapters.html | 27 ++++- 7 files changed, 445 insertions(+), 140 deletions(-) create mode 100644 docs/channel-model.html diff --git a/docs/channel-model.html b/docs/channel-model.html new file mode 100644 index 0000000..7e68add --- /dev/null +++ b/docs/channel-model.html @@ -0,0 +1,182 @@ + + + + + + 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.
      +
      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 the elimination / multi-main / ZippyQ bracket formats.
      +
      +

      + 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/event-pipeline.html b/docs/event-pipeline.html index 13005b8..f3ea33d 100644 --- a/docs/event-pipeline.html +++ b/docs/event-pipeline.html @@ -31,6 +31,22 @@

      Event Pipeline

  • - {h.heat} + {heatDisplayName(round, h)} {#if h.is_current}Current{/if} {statusLabel(h)} { expect(dashes.length).toBe(2); }); + it("names an open-practice round's auto-created heat 'Open Practice Heat' (not its id)", async () => { + // An open-practice round (open_practice format + AllChannels seeding) auto-creates one heat; + // it should display as "Open Practice Heat" rather than the generated heat id. + const OP_ROUND: RoundDef = { + id: 'op1', + label: 'Open Practice', + classes: [], + format: 'open_practice', + params: {}, + win_condition: 'BestLap', + seeding: { AllChannels: { channels: [0, 1] } }, + channel_mode: 'PerHeat', + staging_timer_secs: 300, + start_procedure: { mode: 'randomized-delay', min_delay_ms: 2000, max_delay_ms: 5000 }, + grace_window: { Duration: { micros: 3000000 } } + }; + const heat: HeatSummary = { + heat: 'op1-heat-7x2', + lineup: ['p1', 'p2'], + round: 'op1', + phase: 'Scheduled', + is_current: false + }; + const { session } = makeTestSession({ + ...heatsImpls([heat]), + event: { ...EVENT_WITH_MEMBERS, rounds: [OP_ROUND] } + }); + render(EventRounds, { session }); + + // The heat shows the friendly name; the generated id is not rendered. + const nameEl = await screen.findByText('Open Practice Heat'); + expect(nameEl).toHaveClass('heat-id'); + expect(screen.queryByText('op1-heat-7x2')).toBeNull(); + }); + it('fills a round via FillRound and re-reads the heats list', async () => { const impls = heatsImpls([]); const newHeat: HeatSummary = { diff --git a/frontend/e2e/open-practice.spec.ts b/frontend/e2e/open-practice.spec.ts index 0f2cbc5..347d72d 100644 --- a/frontend/e2e/open-practice.spec.ts +++ b/frontend/e2e/open-practice.spec.ts @@ -94,6 +94,8 @@ test('RD defines an open-practice round, picks active channels, and runs a per-c await expect(heatSection.getByRole('button', { name: 'Fill next heat' })).toHaveCount(0); // The auto-created heat lands (its node lineup shows in the heats list). await expect(heatSection.getByText(/node-0/).first()).toBeVisible({ timeout: 15_000 }); + // It displays under the friendly name "Open Practice Heat", not its generated heat id. + await expect(heatSection.getByText('Open Practice Heat').first()).toBeVisible(); // ── Live control → the per-channel practice board ─────────────────────────────────────────── await openTab(page, 'Live control'); From 0a05915ca176178a8fd1f1ae12661c1010b5b78c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:49:24 +0000 Subject: [PATCH 151/362] fix(server): carry server-authoritative race timing on LiveRaceState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The race clock was a per-client wall-clock, so the header timer and the live-page Heat Time drifted (~7s on a late join) and froze a tick past the real end. Make the server the authority for race timing. - LiveRaceState gains additive `race_started_at` / `race_ended_at` (µs, server wall clock) — the `Armed → Running` and `Running → Unofficial` transition instants, the real race-go and race-end. - `AppState::append` stamps a heat transition's `recorded_at` at the append choke point when the caller supplies none, so the timing is recorded; a caller-supplied timestamp (replay / test) still wins, keeping the fold pure and recomputable. - `with_heat_timing` / `heat_timing` fold the current heat's instants out of the stored log; the snapshot and change-stream paths read the stored log (`read_stored`) so every scope carries the timing. Tests: heat_timing folds from the Running/Finished transitions, clears the end on a re-run after abort, and with_heat_timing sets the current heat's instants. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- bindings/LiveRaceState.ts | 23 ++++- crates/server/src/app.rs | 62 ++++++++++-- crates/server/src/live_state.rs | 166 +++++++++++++++++++++++++++++++- crates/server/src/ws.rs | 26 +++-- 4 files changed, 259 insertions(+), 18 deletions(-) diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts index 137e5b8..2abc7e9 100644 --- a/bindings/LiveRaceState.ts +++ b/bindings/LiveRaceState.ts @@ -42,4 +42,25 @@ 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, }; +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, }; diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 0e80185..cf087e0 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -100,7 +100,7 @@ use crate::events::{ SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state}; +use crate::live_state::{HeatSummary, heat_summaries, live_state, with_heat_timing}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; @@ -260,6 +260,14 @@ impl AppState { /// 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. The runtime/control paths append them with no caller timestamp, so + // stamp the server wall clock here (the single append choke point) when one is absent — + // making the transition's `recorded_at` the authoritative timing every client reads. + // A caller-supplied timestamp (a replay, a test pinning an instant) still wins. + let recorded_at = recorded_at + .or_else(|| matches!(event, Event::HeatStateChanged { .. }).then(now_micros)); let offset = { let mut log = self.log.lock().map_err(|_| { ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned") @@ -314,6 +322,24 @@ impl AppState { 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). @@ -1349,13 +1375,18 @@ async fn snapshot_event( Path((event_id, _event)): Path<(EventId, EventId)>, ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; - let (events, cursor) = state.read()?; + 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. - let body = state.open_practice().merge_into(live_state(&events)); + // `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), @@ -1374,11 +1405,14 @@ async fn snapshot_class( Path((event_id, _event, class)): Path<(EventId, EventId, ClassId)>, ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; - let (events, cursor) = state.read()?; + let (stored, cursor) = state.read_stored()?; + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); let class_events = class_window(&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(live_state(&class_events)), + body: ProjectionBody::LiveRaceState(with_heat_timing(live_state(&class_events), &stored)), })) } @@ -1436,7 +1470,8 @@ async fn snapshot_heat( Query(query): Query, ) -> Result, ProtocolError> { let state = resolve_event(®istry, &event_id)?; - let (events, cursor) = state.read()?; + 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 @@ -1458,7 +1493,9 @@ async fn snapshot_heat( // 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(live_state(&heat_events)), + state + .open_practice() + .merge_into(with_heat_timing(live_state(&heat_events), &stored)), ) } HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( @@ -1538,6 +1575,17 @@ async fn snapshot_pilot( })) } +/// 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) +} + /// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start. pub(crate) fn first_pass_at(event: &Event) -> Option { match event { diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index eea76a9..261d5ff 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -41,8 +41,9 @@ use std::collections::BTreeMap; use gridfpv_engine::heat::{HeatState, heat_state}; -use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, PilotId, RoundId}; +use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, HeatTransition, PilotId, RoundId}; use gridfpv_projection::{CompetitorKey, lap_list_marshaled, registrations}; +use gridfpv_storage::StoredEvent; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -82,6 +83,27 @@ pub struct LiveRaceState { #[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, } impl Default for LiveRaceState { @@ -94,6 +116,8 @@ impl Default for LiveRaceState { progress: Vec::new(), running_order: Vec::new(), on_deck: None, + race_started_at: None, + race_ended_at: None, } } } @@ -187,7 +211,63 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { 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, + } +} + +/// 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) +} + +/// Set a folded [`LiveRaceState`]'s server-authoritative timing 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. +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; + } + live } /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. @@ -683,6 +763,90 @@ mod tests { 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); + } + #[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 diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs index cb132d5..c53c7e7 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -79,11 +79,12 @@ 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, heat_window, resolve_event}; use crate::error::{ErrorCode, ProtocolError}; use crate::events::EventRegistry; -use crate::live_state::live_state; +use crate::live_state::{live_state, with_heat_timing}; use crate::scope::{EventId, Scope}; use crate::snapshot::{ProjectionBody, ProjectionKind}; use crate::stream::{Change, ChangeEnvelope, Cursor, StreamMessage}; @@ -174,14 +175,19 @@ impl ScopeProjection { /// unaffected (open practice is per channel, not per pilot). fn fold( scope: &Scope, - events: &[Event], + 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 { .. } | Scope::Class { .. } => { // 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). - let mut live = live_state(events); + // `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); } @@ -196,10 +202,11 @@ impl ScopeProjection { if !scheduled { return None; } - // The heat's phase/clock are its real log window; splice the open-practice laps onto - // it when this Heat scope addresses the active open-practice heat. + // 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 = heat_window(events, heat); - let mut live = live_state(&window); + let mut live = with_heat_timing(live_state(&window), stored); if let Some(op) = overlay { if op.active_heat().as_ref() == Some(heat) { live = op.merge_into(live); @@ -327,8 +334,9 @@ async fn run_stream(mut socket: WebSocket, state: AppState) { let mut wake = std::pin::pin!(appended.notified()); wake.as_mut().enable(); - // Pull the current log and fold any new events into envelopes. - let events = match state.read() { + // 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; @@ -408,7 +416,7 @@ impl Engine { /// frame. fn advance( &mut self, - events: &[Event], + events: &[StoredEvent], overlay: Option<&crate::open_practice::OpenPracticeLive>, ) -> Vec { let mut out = Vec::new(); From 56954f227745508842225c4769f2ab2e1dab3a86 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 21:49:34 +0000 Subject: [PATCH 152/362] fix(rd-console): drive the race clock off server timing (header == HUD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useRaceClock` no longer counts from a per-instance wall-clock start; it counts from the live state's `race_started_at` and freezes at exactly `race_ended_at - race_started_at`: - Running: `elapsed = now - race_started_at` — every instance shares the one server anchor, so the header and the HUD agree and read accurately from the real race-go regardless of when each mounted (a late join / reload no longer counts from arrival). - Unofficial/Final: freeze at the exact server-side duration, killing the ~0.1s overshoot (a 60s limit reads 1:00.000, not 1:00.100 or 0:53). - Scheduled/idle: reset to 0 as before. ContextHeader and LiveRaceControl pass the live state's timing into the shared hook, so the two clocks are now identical. Tests: unit tests pin same-anchor → same elapsed regardless of mount time, a mount-after-race-go reads the real elapsed, and the frozen value equals the exact server duration. The race e2e navigates to Live mid-race and asserts header == HUD, and that the frozen header/HUD read the identical value. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../apps/rd-console/src/ContextHeader.svelte | 12 +- .../rd-console/src/lib/raceClock.svelte.ts | 73 ++++++--- .../src/screens/LiveRaceControl.svelte | 14 +- .../rd-console/tests/ContextHeader.test.ts | 34 ++-- .../rd-console/tests/LiveRaceControl.test.ts | 88 +++++++--- .../rd-console/tests/raceClock.svelte.test.ts | 152 ++++++++++++------ frontend/e2e/race.spec.ts | 38 ++++- 7 files changed, 298 insertions(+), 113 deletions(-) diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index f4743de..d8fba18 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -51,9 +51,15 @@ const phase = $derived(heat ? (live?.phase ?? 'Scheduled') : undefined); // The shared race clock (#62): ticks while Running, freezes on Unofficial/Final, resets - // otherwise. Reads the live phase reactively so it tracks the heat from one place — the - // SAME source the live screen drives, so the two never contradict. - const clock = useRaceClock(() => phase); + // otherwise. Server-time-authoritative (#62 follow-up) — it counts from the live state's + // `race_started_at` / `race_ended_at` (the server's race-go / race-end instants), the SAME + // anchor the live screen uses, so the header and the HUD read identically and accurately + // regardless of when each mounted (no more per-client wall-clock drift). + const clock = useRaceClock( + () => phase, + () => live?.race_started_at, + () => live?.race_ended_at + ); // Show the heat clock while it's meaningful: ticking during the race (Running) and frozen at // the race-end value once the race has closed (Unofficial) and through finalize (Final). Before // the race starts (Scheduled/Staged/Armed) the clock reads 0, so we keep it hidden to avoid a diff --git a/frontend/apps/rd-console/src/lib/raceClock.svelte.ts b/frontend/apps/rd-console/src/lib/raceClock.svelte.ts index c2d36cf..6211851 100644 --- a/frontend/apps/rd-console/src/lib/raceClock.svelte.ts +++ b/frontend/apps/rd-console/src/lib/raceClock.svelte.ts @@ -2,20 +2,36 @@ * The shared race clock (#62), lifted out of {@link LiveRaceControl} so the persistent * {@link ContextHeader} and the live screen drive the **same** clock from one place (#85). * - * Behaviour, keyed off the live `phase`: - * • Running → tick a wall-clock timer (`Date.now() - start`) every 50ms. - * • Unofficial / Final → freeze the clock at its last ticked value (stop ticking). + * **Server-time-authoritative (#62 follow-up).** The elapsed is no longer a per-component + * wall-clock that starts when *this* instance first sees `Running` — that made the header and + * the live HUD drift (each anchored to whenever it happened to mount) and freeze a tick past + * the real end. Instead it counts from the server's authoritative race timing carried on + * `LiveRaceState`: + * • `race_started_at` — the µs instant the heat went `Running` (the real race-go). + * • `race_ended_at` — the µs instant it went `Unofficial` (the race-end), once ended. + * + * Behaviour, keyed off the live `phase` and that timing: + * • Running → `elapsed = now - race_started_at`, ticked every 50ms. Every + * instance shares the one server anchor, so the header and the HUD **agree** and read + * accurately from the real race-go regardless of when each mounted (a late join / reload + * no longer counts from arrival). + * • Unofficial / Final → freeze at the **exact** server duration + * `race_ended_at - race_started_at` — killing the ~0.1s overshoot a poll-late freeze left + * (a 60s limit reads 1:00.000, not 1:00.100). * • Scheduled/Staged/Armed → reset to 0 (a fresh/idle heat, or no heat at all). * The off-ramps Abort/Restart/Discard aren't phases of their own — they fold back onto one * of the above (typically Scheduled), so they fall out of these same rules. * - * This is the v1, *approximate* clock: on a late join (the heat is already Running when this - * mounts, or after a reconnect) it counts from "now", not the real heat start, so the time can - * read short. The fuller fix (#62 follow-up) is a server-authoritative start time in - * `LiveRaceState`. Sharing the logic here means that fix lands in exactly one place. + * Skew note: `now` is the client clock and `race_started_at` the server's. On loopback/LAN the + * skew is small and — crucially — *shared* by every instance, so the two clocks are consistent + * (the bug being fixed). The frozen value uses only server instants, so it is exact. * * Usage (inside a component, so the `$effect` has an owner): - * const clock = useRaceClock(() => session.liveState?.phase ?? 'Scheduled'); + * const clock = useRaceClock( + * () => session.liveState?.phase ?? 'Scheduled', + * () => session.liveState?.race_started_at, + * () => session.liveState?.race_ended_at + * ); * */ @@ -26,32 +42,51 @@ export interface RaceClockState { } /** - * Create a phase-driven race clock. `getPhase` is read reactively, so the timer starts, - * freezes, and resets as the live phase changes. Must be called during component setup so - * its internal `$effect` is owned (and torn down on unmount). + * Create a phase-driven, **server-time-anchored** race clock. `getPhase` selects the rule; + * `getStartedAt` / `getEndedAt` supply the server's `race_started_at` / `race_ended_at` + * (microseconds, from `LiveRaceState`). All are read reactively, so the clock starts, freezes, + * and resets as the live state changes. Must be called during component setup so its internal + * `$effect` is owned (and torn down on unmount). */ -export function useRaceClock(getPhase: () => string | undefined): RaceClockState { +export function useRaceClock( + getPhase: () => string | undefined, + getStartedAt: () => number | null | undefined, + getEndedAt: () => number | null | undefined +): RaceClockState { let elapsedMs = $state(0); $effect(() => { - // `phase` is the only reactive read in this effect, so it re-runs *only* on a phase - // change — not on every render — which keeps the clock from restarting spuriously. const phase = getPhase(); + const startedAtMicros = getStartedAt(); + const endedAtMicros = getEndedAt(); + if (phase === 'Running') { - const startedAt = Date.now(); + // No server start yet (a brief window before the `Running` transition's timestamp has + // propagated): hold at 0 rather than counting from an unknown anchor. + if (startedAtMicros == null) { + elapsedMs = 0; + return; + } + const startedAtMs = startedAtMicros / 1000; + // All instances count from the SAME server anchor → header and HUD agree, accurate from + // the real race-go regardless of when this clock mounted. const advance = () => { - elapsedMs = Date.now() - startedAt; + elapsedMs = Math.max(0, Date.now() - startedAtMs); }; advance(); const id = setInterval(advance, 50); - // Teardown: stop ticking when the phase leaves Running (or on unmount). The next - // effect run applies the freeze (Unofficial/Final) or reset (everything else). return () => clearInterval(id); } + if (phase === 'Unofficial' || phase === 'Final') { - // Freeze: leave `elapsedMs` at its last Running value. + // Freeze at the EXACT server-side race duration. Falls back to the last ticked value only + // if the server timing is somehow absent (an older log), preserving the prior behaviour. + if (startedAtMicros != null && endedAtMicros != null) { + elapsedMs = Math.max(0, (endedAtMicros - startedAtMicros) / 1000); + } return; } + // Scheduled / Staged / Armed / no heat (incl. where Abort/Restart/Discard land): reset. elapsedMs = 0; }); diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 543d9b7..7d68ca9 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -86,10 +86,16 @@ const hasChannels = $derived(currentChannels.size > 0); // ── Race clock (#62) ──────────────────────────────────────────────────────────────── - // The phase-driven elapsed clock now lives in the shared `useRaceClock` helper so the - // persistent ContextHeader (#85) and this screen drive the *same* clock from one place - // (ticks while Running, freezes on Unofficial/Final, resets otherwise). See raceClock.svelte.ts. - const clock = useRaceClock(() => phase); + // The phase-driven elapsed clock lives in the shared `useRaceClock` helper so the persistent + // ContextHeader (#85) and this screen drive the *same* clock from one place. It is + // server-time-authoritative (#62 follow-up): it counts from the live state's `race_started_at` + // / `race_ended_at` (the server's race-go / race-end instants), so this HUD and the header + // agree exactly and the frozen value is the precise server-side duration. See raceClock.svelte.ts. + const clock = useRaceClock( + () => phase, + () => live?.race_started_at, + () => live?.race_ended_at + ); const elapsedMs = $derived(clock.elapsedMs); // ── The current heat's round config (heat-lifecycle Slice 3) ───────────────────────────────── diff --git a/frontend/apps/rd-console/tests/ContextHeader.test.ts b/frontend/apps/rd-console/tests/ContextHeader.test.ts index c5ddb14..f681bf9 100644 --- a/frontend/apps/rd-console/tests/ContextHeader.test.ts +++ b/frontend/apps/rd-console/tests/ContextHeader.test.ts @@ -11,17 +11,32 @@ import { makeTestSession } from './support.js'; * rather than vanishing the instant the race closes (which made the header contradict the live * screen, where the frozen clock stays on view). Before the race it's hidden (the clock reads 0). */ -const liveAt = (phase: LiveRaceState['phase']): LiveRaceState => - ({ current_heat: 'heat-1', phase }) as LiveRaceState; +// A `LiveRaceState` carrying the server's race timing (µs). The header clock is now +// server-time-authoritative (#62 follow-up): it counts from `race_started_at` and freezes at +// the exact `race_ended_at - race_started_at`. +const liveAt = ( + phase: LiveRaceState['phase'], + opts: { startedAtMs?: number | null; endedAtMs?: number | null } = {} +): LiveRaceState => + ({ + current_heat: 'heat-1', + phase, + race_started_at: opts.startedAtMs == null ? undefined : opts.startedAtMs * 1000, + race_ended_at: opts.endedAtMs == null ? undefined : opts.endedAtMs * 1000 + }) as LiveRaceState; const clockEl = () => document.querySelector('.ctx-clock .gridfpv-race-clock'); describe('ContextHeader heat clock', () => { - beforeEach(() => vi.useFakeTimers()); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); afterEach(() => vi.useRealTimers()); it('ticks while Running, then stays frozen and visible through Unofficial and Final', async () => { - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + // Server race-go at t=0; the header anchors to it. + const { session, pushLive } = makeTestSession({ live: liveAt('Running', { startedAtMs: 0 }) }); render(ContextHeader, { session, ongolive: () => {}, onswitchevent: () => {} }); // The clock is on view while Running and advancing. @@ -30,10 +45,11 @@ describe('ContextHeader heat clock', () => { vi.advanceTimersByTime(4000); await tick(); const atEnd = clockEl()?.textContent ?? ''; - expect(atEnd).not.toBe('0:00.000'); + expect(atEnd).toBe('0:04.000'); - // Time limit fires → Unofficial: the clock must remain visible AND stop changing. - pushLive(liveAt('Unofficial')); + // Time limit fires → Unofficial at exactly 4.000s: the clock stays visible AND frozen at the + // exact server duration. + pushLive(liveAt('Unofficial', { startedAtMs: 0, endedAtMs: 4_000 })); await tick(); expect(clockEl()).not.toBeNull(); const frozen = clockEl()?.textContent ?? ''; @@ -43,8 +59,8 @@ describe('ContextHeader heat clock', () => { await tick(); expect(clockEl()?.textContent).toBe(frozen); - // Finalize → Final: still visible, still frozen. - pushLive(liveAt('Final')); + // Finalize → Final: still visible, still frozen at the exact value. + pushLive(liveAt('Final', { startedAtMs: 0, endedAtMs: 4_000 })); await tick(); vi.advanceTimersByTime(5000); await tick(); diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index f100c6f..ae0b29d 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -371,18 +371,34 @@ describe('LiveRaceControl', () => { describe('race clock (#62)', () => { // The pure `RaceClock` renders `M:SS.mmm` into a `role="timer"`; we read that text to - // assert the client-side ticking driven from the live `phase` in LiveRaceControl. + // assert the SERVER-TIME-ANCHORED clock (#62 follow-up): the elapsed counts from the live + // state's `race_started_at` (µs) and freezes at `race_ended_at - race_started_at`. const clockText = () => screen.getByRole('timer').textContent?.trim(); - // A bare `LiveRaceState` at the given phase (with/without a heat on the timer). - const liveAt = (phase: LiveRaceState['phase'], heat: string | undefined = 'heat-1') => - ({ current_heat: heat, phase }) as LiveRaceState; + // A `LiveRaceState` at the given phase, carrying server timing in **microseconds** (the wire + // unit). `startedAtMs` / `endedAtMs` are the server's race-go / race-end instants in ms. + const liveAt = ( + phase: LiveRaceState['phase'], + opts: { + heat?: string | undefined; + startedAtMs?: number | null; + endedAtMs?: number | null; + } = {} + ) => { + const heat = 'heat' in opts ? opts.heat : 'heat-1'; + return { + current_heat: heat, + phase, + race_started_at: opts.startedAtMs == null ? undefined : opts.startedAtMs * 1000, + race_ended_at: opts.endedAtMs == null ? undefined : opts.endedAtMs * 1000 + } as LiveRaceState; + }; afterEach(() => { vi.useRealTimers(); }); - it('starts ticking when the phase becomes Running', async () => { + it('starts ticking when the phase becomes Running, anchored to the server race-start', async () => { vi.useFakeTimers(); vi.setSystemTime(0); const { session, pushLive } = makeTestSession({ live: liveAt('Armed') }); @@ -390,9 +406,10 @@ describe('LiveRaceControl', () => { // Idle/pre-race: clock sits at zero. expect(clockText()).toBe('0:00.000'); - pushLive(liveAt('Running')); + // The server stamps race-go at t=0 (the current wall time). + pushLive(liveAt('Running', { startedAtMs: 0 })); await tick(); - // Advance wall-clock + the tick interval; the clock reflects the elapsed time. The + // Advance wall-clock + the tick interval; the clock reflects now - race_started_at. The // display only updates on a 50ms tick, so we advance by exact tick multiples. await vi.advanceTimersByTimeAsync(1_250); expect(clockText()).toBe('0:01.250'); @@ -401,18 +418,35 @@ describe('LiveRaceControl', () => { expect(clockText()).toBe('1:01.250'); }); - it('freezes the clock when the phase becomes Unofficial, and stops ticking', async () => { + it('reads the real elapsed when Running is observed AFTER race-go (late join)', async () => { + // The bug: navigating to Live mid-race used to count from arrival (0), lagging the header. + // Anchored to the server `race_started_at`, the first Running snapshot already reads the + // real elapsed regardless of when this screen mounted. + vi.useFakeTimers(); + vi.setSystemTime(7_000); // the race started 7s ago (race_started_at = 0) + const { session, pushLive } = makeTestSession({ live: liveAt('Armed') }); + render(LiveRaceControl, { session }); + + pushLive(liveAt('Running', { startedAtMs: 0 })); + await tick(); + await vi.advanceTimersByTimeAsync(0); + expect(clockText()).toBe('0:07.000'); + }); + + it('freezes at the EXACT server duration on Unofficial, and stops ticking', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + const { session, pushLive } = makeTestSession({ + live: liveAt('Running', { startedAtMs: 0 }) + }); render(LiveRaceControl, { session }); await tick(); await vi.advanceTimersByTimeAsync(2_500); expect(clockText()).toBe('0:02.500'); - // Finishing freezes the displayed value… - pushLive(liveAt('Unofficial')); + // The server closed the race at exactly 2.500s — freeze at race_ended_at - race_started_at. + pushLive(liveAt('Unofficial', { startedAtMs: 0, endedAtMs: 2_500 })); await tick(); expect(clockText()).toBe('0:02.500'); @@ -421,17 +455,19 @@ describe('LiveRaceControl', () => { expect(clockText()).toBe('0:02.500'); }); - it('keeps the frozen value through Final', async () => { + it('keeps the exact frozen value through Final', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + const { session, pushLive } = makeTestSession({ + live: liveAt('Running', { startedAtMs: 0 }) + }); render(LiveRaceControl, { session }); await tick(); await vi.advanceTimersByTimeAsync(3_000); - pushLive(liveAt('Unofficial')); + pushLive(liveAt('Unofficial', { startedAtMs: 0, endedAtMs: 3_000 })); await tick(); - pushLive(liveAt('Final')); + pushLive(liveAt('Final', { startedAtMs: 0, endedAtMs: 3_000 })); await tick(); await vi.advanceTimersByTimeAsync(4_000); expect(clockText()).toBe('0:03.000'); @@ -440,14 +476,16 @@ describe('LiveRaceControl', () => { it('resets the clock to zero when the heat goes back to Scheduled (e.g. after an abort)', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + const { session, pushLive } = makeTestSession({ + live: liveAt('Running', { startedAtMs: 0 }) + }); render(LiveRaceControl, { session }); await tick(); await vi.advanceTimersByTimeAsync(2_000); expect(clockText()).toBe('0:02.000'); - // An Abort/Restart folds the phase back to Scheduled → reset to zero. - pushLive(liveAt('Scheduled')); + // An Abort/Restart folds the phase back to Scheduled (timing cleared) → reset to zero. + pushLive(liveAt('Scheduled', { startedAtMs: null })); await tick(); expect(clockText()).toBe('0:00.000'); }); @@ -455,13 +493,15 @@ describe('LiveRaceControl', () => { it('resets to zero when there is no heat on the timer', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + const { session, pushLive } = makeTestSession({ + live: liveAt('Running', { startedAtMs: 0 }) + }); render(LiveRaceControl, { session }); await tick(); await vi.advanceTimersByTimeAsync(1_500); // No current heat → phase defaults to Scheduled → reset. - pushLive(liveAt('Scheduled', undefined)); + pushLive(liveAt('Scheduled', { heat: undefined, startedAtMs: null })); await tick(); expect(clockText()).toBe('0:00.000'); }); @@ -469,14 +509,16 @@ describe('LiveRaceControl', () => { it('does not restart the clock on a repeated Running push (rapid same-phase flips)', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + const { session, pushLive } = makeTestSession({ + live: liveAt('Running', { startedAtMs: 0 }) + }); render(LiveRaceControl, { session }); await tick(); await vi.advanceTimersByTimeAsync(2_000); expect(clockText()).toBe('0:02.000'); - // Another Running snapshot (e.g. progress update) must not reset the start. - pushLive(liveAt('Running')); + // Another Running snapshot (same server anchor) must not reset the start. + pushLive(liveAt('Running', { startedAtMs: 0 })); await tick(); await vi.advanceTimersByTimeAsync(1_000); expect(clockText()).toBe('0:03.000'); diff --git a/frontend/apps/rd-console/tests/raceClock.svelte.test.ts b/frontend/apps/rd-console/tests/raceClock.svelte.test.ts index 957c9ed..ddaece2 100644 --- a/frontend/apps/rd-console/tests/raceClock.svelte.test.ts +++ b/frontend/apps/rd-console/tests/raceClock.svelte.test.ts @@ -3,98 +3,154 @@ import { flushSync } from 'svelte'; import { useRaceClock } from '../src/lib/raceClock.svelte.js'; /** - * Unit tests for the shared phase-driven race clock (#62, #85). Pins the freeze regression - * the Finished→Unofficial rename introduced: the clock must FREEZE on `Unofficial` (the race - * has ended; only the result isn't finalized yet) and stay frozen through `Final`, ticking only - * while `Running` and resetting to 0 on a fresh/idle heat or a heat change. + * Unit tests for the shared, **server-time-authoritative** race clock (#62, #85, #62 follow-up). * - * The rune helper owns an internal `$effect`, so it's driven inside an `$effect.root` with a - * reactive `$state` phase and `flushSync()` to settle the effect after each phase change. Fake - * timers advance the wall clock the helper reads. + * The clock no longer counts from a per-instance wall-clock start; it counts from the server's + * `race_started_at` / `race_ended_at` (µs) carried on `LiveRaceState`. These tests pin: + * • the same `race_started_at` yields the same elapsed regardless of when the clock mounts + * (a mount *after* race-go still reads the real elapsed, not 0) — the header↔HUD agreement; + * • the frozen value equals exactly `race_ended_at - race_started_at` (no poll-late overshoot); + * • Running ticks, Unofficial/Final freeze, Scheduled/idle reset. + * + * The rune helper owns an internal `$effect`, driven inside an `$effect.root` with reactive + * `$state` for phase + the two server instants, and `flushSync()` to settle after each change. + * Fake timers drive both the helper's `setInterval` and `Date.now()`. */ -describe('useRaceClock', () => { - beforeEach(() => vi.useFakeTimers()); +describe('useRaceClock (server-time-authoritative)', () => { + // Anchor the fake clock at a fixed wall time so `Date.now()` and the µs anchors are comparable. + const T0 = 1_000_000_000_000; // ms + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(T0); + }); afterEach(() => vi.useRealTimers()); - /** Run `fn` with a reactive phase setter inside an owned effect root; returns a cleanup. */ - function harness() { - let phase = $state('Scheduled'); + /** Run inside an owned effect root with reactive phase + server instants; returns a handle. */ + function harness(initial?: { + phase?: string; + startedAtMicros?: number | null; + endedAtMicros?: number | null; + }) { + let phase = $state(initial?.phase ?? 'Scheduled'); + let startedAt = $state(initial?.startedAtMicros ?? null); + let endedAt = $state(initial?.endedAtMicros ?? null); let clock!: ReturnType; const cleanup = $effect.root(() => { - clock = useRaceClock(() => phase); + clock = useRaceClock( + () => phase, + () => startedAt, + () => endedAt + ); }); flushSync(); return { get elapsedMs() { return clock.elapsedMs; }, - setPhase(p: string | undefined) { - phase = p; + set(next: { + phase?: string; + startedAtMicros?: number | null; + endedAtMicros?: number | null; + }) { + if ('phase' in next) phase = next.phase; + if ('startedAtMicros' in next) startedAt = next.startedAtMicros; + if ('endedAtMicros' in next) endedAt = next.endedAtMicros; flushSync(); }, cleanup }; } - it('ticks up while Running', () => { - const h = harness(); - h.setPhase('Running'); + it('ticks up while Running, anchored to the server race-start', () => { + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000 }); expect(h.elapsedMs).toBe(0); vi.advanceTimersByTime(3000); expect(h.elapsedMs).toBeGreaterThanOrEqual(2900); + expect(h.elapsedMs).toBeLessThanOrEqual(3100); h.cleanup(); }); - it('freezes at the race-end value on Unofficial and does not keep counting', () => { - const h = harness(); - h.setPhase('Running'); - vi.advanceTimersByTime(5000); - const atEnd = h.elapsedMs; - expect(atEnd).toBeGreaterThanOrEqual(4900); + it('mounting AFTER race-go reads the real elapsed, not 0 (header == HUD)', () => { + // The race started 7s before this clock mounts (the bug: a late-joining live page used to + // count from arrival and disagree with the always-mounted header). Both now anchor to the + // same server `race_started_at`, so a fresh mount immediately reads ~7s. + const startedAtMicros = (T0 - 7000) * 1000; // 7s ago + const h = harness({ phase: 'Running', startedAtMicros }); + expect(h.elapsedMs).toBeGreaterThanOrEqual(6900); + expect(h.elapsedMs).toBeLessThanOrEqual(7100); + + // A second clock mounting at the same instant with the same anchor reads the SAME value. + const h2 = harness({ phase: 'Running', startedAtMicros }); + expect(h2.elapsedMs).toBe(h.elapsedMs); + h.cleanup(); + h2.cleanup(); + }); - // The time limit fires → Running transitions to Unofficial. The clock must STOP. - h.setPhase('Unofficial'); - const frozen = h.elapsedMs; - expect(frozen).toBe(atEnd); + it('freezes at EXACTLY race_ended_at - race_started_at on Unofficial', () => { + const startedAtMicros = T0 * 1000; + // A clean 60.000s race window from the server. + const endedAtMicros = (T0 + 60_000) * 1000; + const h = harness({ phase: 'Running', startedAtMicros }); + vi.advanceTimersByTime(60_100); // poll a tenth late, as the real bug did - // Wall clock keeps moving, but the frozen reading must not advance (the bug: it free-ran). + h.set({ phase: 'Unofficial', endedAtMicros }); + // Exactly 60_000 ms — not 60_100 (no overshoot), not a short late-join value. + expect(h.elapsedMs).toBe(60_000); + + // The frozen value must not advance as the wall clock keeps moving. vi.advanceTimersByTime(10_000); - expect(h.elapsedMs).toBe(frozen); + expect(h.elapsedMs).toBe(60_000); h.cleanup(); }); - it('stays frozen through Final (finalize does not restart the clock)', () => { - const h = harness(); - h.setPhase('Running'); - vi.advanceTimersByTime(4000); - h.setPhase('Unofficial'); - const frozen = h.elapsedMs; + it('stays frozen at the exact duration through Final', () => { + const startedAtMicros = T0 * 1000; + const endedAtMicros = (T0 + 42_000) * 1000; + const h = harness({ phase: 'Running', startedAtMicros }); + vi.advanceTimersByTime(42_000); + h.set({ phase: 'Unofficial', endedAtMicros }); + expect(h.elapsedMs).toBe(42_000); - h.setPhase('Final'); + h.set({ phase: 'Final' }); vi.advanceTimersByTime(5000); - expect(h.elapsedMs).toBe(frozen); + expect(h.elapsedMs).toBe(42_000); h.cleanup(); }); it('resets to 0 on a new/idle heat (Scheduled) after a frozen race', () => { - const h = harness(); - h.setPhase('Running'); - vi.advanceTimersByTime(4000); - h.setPhase('Unofficial'); - expect(h.elapsedMs).toBeGreaterThan(0); + const startedAtMicros = T0 * 1000; + const endedAtMicros = (T0 + 30_000) * 1000; + const h = harness({ phase: 'Running', startedAtMicros }); + vi.advanceTimersByTime(30_000); + h.set({ phase: 'Unofficial', endedAtMicros }); + expect(h.elapsedMs).toBe(30_000); - // A fresh heat is Scheduled again → reset. - h.setPhase('Scheduled'); + // A fresh heat: phase Scheduled, server timing cleared → reset. + h.set({ phase: 'Scheduled', startedAtMicros: null, endedAtMicros: null }); expect(h.elapsedMs).toBe(0); h.cleanup(); }); it('resets to 0 when the heat goes idle (no heat / undefined phase)', () => { - const h = harness(); - h.setPhase('Running'); + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000 }); vi.advanceTimersByTime(2000); - h.setPhase(undefined); + h.set({ phase: undefined, startedAtMicros: null }); expect(h.elapsedMs).toBe(0); h.cleanup(); }); + + it('holds at 0 while Running before the server race-start has propagated', () => { + // A brief window: phase is Running but `race_started_at` has not arrived yet. Holding at 0 + // is correct — counting from an unknown anchor would be wrong. + const h = harness({ phase: 'Running', startedAtMicros: null }); + expect(h.elapsedMs).toBe(0); + vi.advanceTimersByTime(1000); + expect(h.elapsedMs).toBe(0); + // Once the anchor lands, it counts. + h.set({ startedAtMicros: T0 * 1000 }); + vi.advanceTimersByTime(2000); + expect(h.elapsedMs).toBeGreaterThanOrEqual(1900); + h.cleanup(); + }); }); diff --git a/frontend/e2e/race.spec.ts b/frontend/e2e/race.spec.ts index 2391cea..0454a36 100644 --- a/frontend/e2e/race.spec.ts +++ b/frontend/e2e/race.spec.ts @@ -123,6 +123,33 @@ test('RD drives a full basic sim race through the console UI', async ({ page, di .poll(totalLaps, { timeout: 30_000, message: 'live lap counts should climb in the DOM' }) .toBeGreaterThan(early); + // ── Mid-race: the header clock and the HUD clock AGREE (the #62 follow-up fix) ────────── + // Both are independent `useRaceClock` instances, now anchored to the server's `race_started_at` + // rather than to whenever each mounted. Navigating AWAY from Live and BACK remounts the HUD clock + // mid-race (the exact late-join the bug mis-handled — it used to count from arrival and read ~7s + // behind the always-mounted header). After the remount, the two must read the same elapsed. + const parseClock = (t: string | null) => { + const m = /(\d+):(\d+)\.(\d+)/.exec(t ?? ''); + return m ? (+m[1] * 60 + +m[2]) * 1000 + +m[3] : NaN; + }; + const hudClockLive = page.locator('.hud-clock .gridfpv-race-clock'); + const headerClockLive = page.locator('.ctx-clock .gridfpv-race-clock'); + await expect(hudClockLive).toBeVisible(); + await expect(headerClockLive).toBeVisible(); + // Leave Live (remounting nothing in the header — it is persistent), then return so the HUD clock + // mounts fresh well after race-go. + await page.getByRole('button', { name: /Results/ }).click(); + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /Live control/ }).click(); + await expect(hudClockLive).toBeVisible(); + // Sample both in the same tick: the freshly-mounted HUD must agree with the long-lived header + // (within one 50ms tick of sampling jitter), NOT lag by the time we were away. This is the bug. + const hudMid = parseClock(await hudClockLive.textContent()); + const headerMid = parseClock(await headerClockLive.textContent()); + expect(Math.abs(hudMid - headerMid)).toBeLessThan(150); + // And it must read the REAL elapsed (well past 0), proving it counted from race-go not arrival. + expect(hudMid).toBeGreaterThan(1000); + // ── End the window: ForceEnd is the runtime-clock override (the old manual "Finish") ── await page.getByRole('button', { name: 'ForceEnd', exact: true }).click(); await expect(page.locator('.phase').first()).toHaveText('Unofficial'); @@ -139,13 +166,10 @@ test('RD drives a full basic sim race through the console UI', async ({ page, di await expect(headerClock).toBeVisible(); const frozenHud = await hudClock.textContent(); const frozenHeader = await headerClock.textContent(); - // Both read a near-identical race-end time (they share the phase-driven freeze logic) — within a - // few ms, since each captured its Running start independently. - const toMs = (t: string | null) => { - const m = /(\d+):(\d+)\.(\d+)/.exec(t ?? ''); - return m ? (+m[1] * 60 + +m[2]) * 1000 + +m[3] : NaN; - }; - expect(Math.abs(toMs(frozenHud) - toMs(frozenHeader))).toBeLessThan(100); + // Both freeze at the EXACT server-side duration (`race_ended_at - race_started_at`), so they read + // the IDENTICAL value — not merely "within a few ms". This is the #62 follow-up: no per-client + // drift and no poll-late overshoot (the frozen reading is the precise server race length). + expect(frozenHud).toBe(frozenHeader); await page.waitForTimeout(1500); // The bug: the clock free-ran. The fix: each clock STOPS at its race-end value and does not tick. expect(await hudClock.textContent()).toBe(frozenHud); From 688595ad8214c7e67394b65a00df0b8132bf7a3c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 22:15:26 +0000 Subject: [PATCH 153/362] fix: Restart resets heat to Scheduled (match Abort) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the `Restart` heat off-ramp land in `Scheduled` rather than `Staged`, mirroring the recently-changed `Abort`: both now fully reset a committed heat and the RD re-Stages it. `Restart`'s legality is unchanged — it is still only legal from Armed/Running/Unofficial; only its landing state changes. In `engine::heat::next_state`, `Restarted` now maps to `Scheduled`. Updated the module ASCII diagram and the `Restart`/`Restarted` doc comments (engine + events crate), the server `Command::Restart` doc and open-practice doc comments, and the app source bridge comment. Tests updated to assert the `Scheduled` landing: engine `restart_*` + `restart_and_discard_land_correctly`; the open-practice `merge_*_on_restart` server unit test; and the `race_flow` open-practice e2e (Restart now serves phase `Scheduled`). The `apply`-legality table is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/app/src/source.rs | 2 +- crates/app/tests/race_flow.rs | 27 +++++++++++++++------------ crates/engine/src/heat.rs | 24 ++++++++++++++---------- crates/events/src/lib.rs | 2 +- crates/server/src/control.rs | 2 +- crates/server/src/open_practice.rs | 27 ++++++++++++++------------- 6 files changed, 46 insertions(+), 38 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index f14e08a..3fa435a 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -996,7 +996,7 @@ fn handle_transition( // // 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 `Staged` on a `Restart` with no + // 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) diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index 7bb6a8d..84c602c 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -1321,10 +1321,11 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() "open-practice rows stay unbound (per channel)" ); - // (e) Restart → Staged: the RD restarts the closed practice. The engine lands the heat in - // `Staged`; the bridge clears the accumulator (wake-on-clear). The served live state must then - // read **`Staged`** 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"). + // (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, @@ -1334,7 +1335,9 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() .await; wait_until(&state, Duration::from_secs(3), { let heat = heat.clone(); - move |events| heat_state_of(events, &heat) == Some(gridfpv_engine::heat::HeatState::Staged) + move |events| { + heat_state_of(events, &heat) == Some(gridfpv_engine::heat::HeatState::Scheduled) + } }) .await; @@ -1349,20 +1352,20 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() tokio::time::sleep(Duration::from_millis(25)).await; } - let staged = served_live(&heat).await; + let scheduled = served_live(&heat).await; assert_eq!( - staged.phase, - gridfpv_server::snapshot::HeatPhase::Staged, - "after Restart the served phase is the log's Staged — never a stale Unofficial" + scheduled.phase, + gridfpv_server::snapshot::HeatPhase::Scheduled, + "after Restart the served phase is the log's Scheduled — never a stale Unofficial" ); assert!( - staged.progress.iter().all(|p| p.laps_completed == 0), + 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 → - // Staged with no synthetic re-`Running` that would reset/bump the console clock. + // Scheduled with no synthetic re-`Running` that would reset/bump the console clock. let log = read_log(&state); let restarts = log .iter() @@ -1370,7 +1373,7 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() .count(); assert_eq!( restarts, 1, - "exactly one Restarted lands the heat in Staged" + "exactly one Restarted resets the heat to Scheduled" ); } diff --git a/crates/engine/src/heat.rs b/crates/engine/src/heat.rs index d6b9726..3744638 100644 --- a/crates/engine/src/heat.rs +++ b/crates/engine/src/heat.rs @@ -13,9 +13,9 @@ //! Staged --> Scheduled : abort //! Armed --> Scheduled : abort //! Running --> Scheduled : abort -//! Running --> Staged : restart -//! Armed --> Staged : restart -//! Unofficial --> Staged : restart +//! Running --> Scheduled : restart +//! Armed --> Scheduled : restart +//! Unofficial --> Scheduled : restart //! Final --> Scheduled : discard & re-run //! ``` //! @@ -122,7 +122,8 @@ pub enum HeatCommand { /// 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 committed heat from staging (Armed/Running/Unofficial → 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 finalized heat for a re-run (Final → Scheduled). Discard, @@ -177,7 +178,8 @@ pub fn apply(state: HeatState, command: HeatCommand) -> Result HeatTransition::Aborted, // Restart applies to any committed heat short of finalized (Armed/Running/ - // Unofficial), back to staging for a clean re-run. + // 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, @@ -196,7 +198,7 @@ pub fn apply(state: HeatState, command: HeatCommand) -> Result HeatState { // Abort always resets the heat to Scheduled (from any abortable state), so the // RD re-Stages it. T::Aborted => S::Scheduled, - T::Restarted => S::Staged, + // 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, } } @@ -491,13 +495,13 @@ mod tests { } #[test] - fn restart_is_legal_from_armed_running_unofficial_landing_staged() { + 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::Staged, "{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] { @@ -520,7 +524,7 @@ mod tests { 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::Running, T::Restarted), S::Scheduled); assert_eq!(next_state(S::Final, T::Discarded), S::Scheduled); } diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 5511d17..f0fef63 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -231,7 +231,7 @@ pub enum HeatTransition { Reverted, /// Abandoned before finalizing (Staged/Armed/Running → Scheduled, so the RD re-Stages). Aborted, - /// A committed heat restarted from staging. + /// A committed heat restarted (Armed/Running/Unofficial → Scheduled, so the RD re-Stages). Restarted, /// A finalized heat discarded for a re-run. Discarded, diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs index cfdd46c..4b9c9ea 100644 --- a/crates/server/src/control.rs +++ b/crates/server/src/control.rs @@ -103,7 +103,7 @@ pub enum Command { /// The heat to transition. heat: HeatId, }, - /// Restart a committed heat — back to staging for a re-run. + /// Restart a committed heat — reset to `Scheduled` for a re-run (the RD re-Stages). Restart { /// The heat to transition. heat: HeatId, diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs index b214720..9550784 100644 --- a/crates/server/src/open_practice.rs +++ b/crates/server/src/open_practice.rs @@ -15,8 +15,8 @@ //! 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` landing the heat in -//! `Staged` was never reflected (so the console kept rendering `Unofficial`/Final buttons and +//! 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). //! @@ -165,7 +165,7 @@ impl OpenPracticeLive { /// 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 → Staged` is + /// 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 @@ -178,7 +178,7 @@ impl OpenPracticeLive { return base; }; // Only splice laps when the overlay's heat is the one the log says is current. Across a - // `Restart` (heat back to `Staged`) the bridge clears the accumulator, but guard anyway so 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; @@ -343,10 +343,11 @@ mod tests { } #[test] - fn merge_follows_the_log_phase_through_unofficial_then_staged_on_restart() { + 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 - // `Staged` — so the merge yields `Staged` with NO stale `Unofficial`. + // `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)]; @@ -375,10 +376,10 @@ mod tests { assert_eq!(unofficial.progress[0].laps_completed, 1); // RD hits Restart → the bridge clears the accumulator and the LOG records `Restarted` - // (heat back to `Staged`). The merge of the now-empty accumulator onto the `Staged` base is - // the bare log state: phase `Staged`, no stale `Unofficial`, laps cleared. + // (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 staged = live.merge_into(log_base( + let scheduled = live.merge_into(log_base( &heat, &channels, &[ @@ -388,12 +389,12 @@ mod tests { ], )); assert_eq!( - staged.phase, - HeatPhase::Staged, - "after Restart the served phase is the log's Staged — never a stale Unofficial" + scheduled.phase, + HeatPhase::Scheduled, + "after Restart the served phase is the log's Scheduled — never a stale Unofficial" ); assert_eq!( - staged.progress[0].laps_completed, 0, + scheduled.progress[0].laps_completed, 0, "the per-channel laps are cleared after Restart" ); } From 7dea63ef0b283ab3b6f5f761938c42cd845b100c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 22 Jun 2026 23:57:58 +0000 Subject: [PATCH 154/362] ux: redesign the Rounds create/edit form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer feedback on the Rounds & Heats stage's round form: 1. Friendly format names — a shared `lib/formats.ts` maps each engine format key to a human label ("Double Elimination", "Open Practice", …) while the wire value stays the key. Reused by the format selector, the Rounds list, and the Heats area. 2. Field order + dynamic fields — the form is now Label → Format, then only the fields the chosen format uses (driven by `fieldsForFormat`): open practice gets channels + time limit; roster/bracket formats get class + win + seeding + channel mode + the format's params. 3. Start-procedure delays in seconds — the min/max inputs are seconds now, converted to/from the stored `*_delay_ms` on save/load (wire type unchanged). 4. Format params surfaced — each format's declared params (rounds, heat_size, metric, bracket_reset, main_size, …) show inline as proper labeled fields in a "Format options" section; the generic add/remove "Format Params" editor is gone. Open practice declares none. 5. Default grace = 30s — `default_grace_window` (events.rs) and the form's new- round default both move 3s → 30s; existing rounds keep their stored value. 6. Eligible class single-select — the multi-select class checkboxes become a single `` value rather than a multi-select. Stored on the + // wire as the existing one-element `classes` list. `''` = none chosen yet. + let selectedClass = $state(''); let format = $state(''); let winKind = $state('Timed'); let winSeconds = $state(120); // Timed window, in seconds (converted to micros on submit). @@ -424,24 +423,24 @@ let seedKind = $state('FromRoster'); let seedSource = $state(''); let seedTopN = $state(8); - // The guided params the RD has added (a subset of the chosen format's schema), each with its - // typed value. `addParam` (a dropdown of the format's not-yet-added params) appends one seeded - // from its default; `removeParam` unsets it. Stored as `key → value` strings, the wire shape. - let params = $state([]); + // The chosen format's params, as a `key → value` map (Rounds form redesign item 4): every param + // the format declares is shown inline as a proper labeled field, seeded from its schema default + // (or the edited round's stored value). On a format switch the map is re-seeded to the new + // format's declared params. The wire shape is this same `key → value` map. + let paramValues = $state>({}); // The round's channel mode (Static = fixed channels / channel-balanced heats; Per-heat = assigned // per heat, for brackets). Defaulted by format on the backend; the toggle overrides it. let channelMode = $state('PerHeat'); - // Which param to add next (the ` - {#if !isOpenPractice} - -
    - {#each eventClasses as cls (cls.id)} - - {/each} -
    -
    - {/if} + + + -
    - - + + {#each eventClasses as cls (cls.id)} + {/each} + {/if} - - {#if !isOpenPractice} + + {#if fields.winCondition} +
    {/if} - {:else} - -
    - - -
    -
    - {/if} -
    +
    + {/if} - {#if isOpenPractice} + {#if fields.timeLimit} + +
    + + +
    +
    + {/if} + + {#if fields.activeChannels} @@ -964,7 +971,43 @@ {/if} - {#if !isOpenPractice} + + {#if fields.seeding} + + + + + {#if seedKind === 'FromRanking'} +
    + + {#if sourceCandidates.length === 0} +

    Add another round first to seed from its ranking.

    + {:else} + + {/if} +
    + + + +
    + {/if} + {/if} + + {#if fields.channelMode} {/if} + + {#if fields.params && formatParams.length > 0} +
    + Format options +
    + {#each formatParams as schema (schema.key)} + {@const value = paramValues[schema.key] ?? ''} + + {#if schema.kind === 'bool'} + + {:else if schema.kind === 'enum'} + + {:else} + + setParamValue(schema.key, (e.currentTarget as HTMLInputElement).value)} + /> + {/if} + + {/each} +
    +
    + {/if} +
    Start & timing
    @@ -1008,148 +1102,35 @@ />
    +
    - + - +
    - {#if !isOpenPractice} - - - - - {#if seedKind === 'FromRanking'} -
    - - {#if sourceCandidates.length === 0} -

    Add another round first to seed from its ranking.

    - {:else} - - {/if} -
    - - - -
    - {/if} - {/if} - - -
    - {#each params as row (row.key)} - {@const schema = paramByKey.get(row.key)} -
    - {schema?.label ?? row.key} -
    - {#if schema?.kind === 'bool'} - - {:else if schema?.kind === 'enum'} - - {:else} - - setParamValue(row.key, (e.currentTarget as HTMLInputElement).value)} - /> - {/if} -
    - -
    - {/each} - - {#if addableParams.length > 0} -
    - - -
    - {:else if formatParams.length > 0} -

    All of this format’s params are added.

    - {/if} -
    -
    -
    - -
    - Custom attributes -

    Anything else — insurance #, FAA/FCC license, bib, sponsor…

    - {#if attrRows.length > 0} -
      - {#each attrRows as row, i (i)} -
    • - - - -
    • - {/each} -
    - {/if} - -
    {#snippet footer()} @@ -632,43 +569,6 @@ min-width: 4.5rem; } - .attrs { - margin: 0; - padding: var(--gf-space-3); - border: 1px solid var(--gf-border-subtle); - border-radius: var(--gf-radius-md); - display: flex; - flex-direction: column; - gap: var(--gf-space-2); - } - .attrs legend { - font-size: var(--gf-font-size-xs); - font-weight: var(--gf-font-weight-semibold); - text-transform: uppercase; - letter-spacing: var(--gf-tracking-caps); - color: var(--gf-text-muted); - padding: 0 var(--gf-space-1); - } - .attrs-hint { - margin: 0; - font-size: var(--gf-font-size-xs); - color: var(--gf-text-faint); - } - .attr-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--gf-space-2); - } - .attr-row { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: var(--gf-space-2); - align-items: center; - } - .confirm-text { margin: 0; color: var(--gf-text-secondary); diff --git a/frontend/apps/rd-console/src/screens/PilotsPage.svelte b/frontend/apps/rd-console/src/screens/PilotsPage.svelte index 0410715..11c5e1e 100644 --- a/frontend/apps/rd-console/src/screens/PilotsPage.svelte +++ b/frontend/apps/rd-console/src/screens/PilotsPage.svelte @@ -32,8 +32,8 @@

    Pilots

    The application-level pilot directory — maintained once here, rostered per event. Each - pilot needs only a callsign; the rest (team, country, color, VTX, IDs, - custom attributes) is optional. + pilot needs only a callsign; the rest (team, country, color, VTX, IDs) is + optional.

    diff --git a/frontend/apps/rd-console/tests/EventClassesRoster.test.ts b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts index 2e0cef2..369df98 100644 --- a/frontend/apps/rd-console/tests/EventClassesRoster.test.ts +++ b/frontend/apps/rd-console/tests/EventClassesRoster.test.ts @@ -5,8 +5,8 @@ import type { ChannelCatalogEntry, Class, EventMeta, Pilot, Timer } from '@gridf import EventClassesRoster from '../src/screens/EventClassesRoster.svelte'; import { makeTestSession } from './support.js'; -const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }; -const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; +const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [] }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [] }; const OPEN: Class = { id: 'open', name: 'Open Class', source: 'Custom' }; const SPEC: Class = { id: 'spec', name: 'Spec', source: 'Custom' }; diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index bfa360d..de5f346 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -16,8 +16,8 @@ import { makeTestSession } from './support.js'; const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; const SPEC: Class = { id: 'c2', name: 'Spec', source: 'Custom' }; -const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [], attributes: {} }; -const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [], attributes: {} }; +const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [] }; +const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [] }; const QUAL: RoundDef = { id: 'r1', diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts index 8bca2d9..a374876 100644 --- a/frontend/apps/rd-console/tests/HomeHub.test.ts +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -6,8 +6,8 @@ import HomeHub from '../src/screens/HomeHub.svelte'; import { makeTestSession } from './support.js'; const PILOTS: Pilot[] = [ - { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }, - { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} } + { id: 'p1', callsign: 'Ace', vtx_types: [] }, + { id: 'p2', callsign: 'Bee', vtx_types: [] } ]; const CLASSES: Class[] = [ { id: 'c1', name: 'Open', source: 'MultiGP' }, diff --git a/frontend/apps/rd-console/tests/PilotManager.test.ts b/frontend/apps/rd-console/tests/PilotManager.test.ts index 39cf297..d3ad19c 100644 --- a/frontend/apps/rd-console/tests/PilotManager.test.ts +++ b/frontend/apps/rd-console/tests/PilotManager.test.ts @@ -11,10 +11,9 @@ const ACE: Pilot = { name: 'Alice', color: '#ff0000', country: 'US', - vtx_types: ['Analog', 'HDZero'], - attributes: { bib: '7' } + vtx_types: ['Analog', 'HDZero'] }; -const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} }; +const BEE: Pilot = { id: 'p2', callsign: 'Bee', vtx_types: [] }; /** Open the Add form via the manager's exported `openAdd()` (the page button calls it). */ async function openAdd(manager: { openAdd: () => void }) { @@ -56,8 +55,7 @@ describe('PilotManager (#74)', () => { const created: Pilot = { id: 'p9', callsign: 'Neo', - vtx_types: ['Analog', 'DJI'], - attributes: {} + vtx_types: ['Analog', 'DJI'] }; const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); const createPilotImpl = vi.fn(async () => created); @@ -113,7 +111,7 @@ describe('PilotManager (#74)', () => { expect(createPilotImpl).not.toHaveBeenCalled(); }); - it('creates a pilot with callsign + name + country + color + a custom attribute', async () => { + it('creates a pilot with callsign + name + country + color', async () => { let calls = 0; const created: Pilot = { id: 'p9', @@ -121,8 +119,7 @@ describe('PilotManager (#74)', () => { name: 'Thomas', country: 'US', color: '#00ff00', - vtx_types: [], - attributes: { sponsor: 'Acme' } + vtx_types: [] }; const listPilotsImpl = vi.fn(async () => (calls++ === 0 ? [] : [created])); const createPilotImpl = vi.fn(async () => created); @@ -135,15 +132,6 @@ describe('PilotManager (#74)', () => { await fireEvent.input(screen.getByLabelText('Real name'), { target: { value: 'Thomas' } }); await fireEvent.input(screen.getByLabelText('Color'), { target: { value: '#00ff00' } }); - // Add a custom attribute row, fill it. - await fireEvent.click(screen.getByRole('button', { name: '+ Add attribute' })); - await fireEvent.input(screen.getByLabelText('Attribute 1 key'), { - target: { value: 'sponsor' } - }); - await fireEvent.input(screen.getByLabelText('Attribute 1 value'), { - target: { value: 'Acme' } - }); - await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); @@ -152,38 +140,13 @@ describe('PilotManager (#74)', () => { expect.objectContaining({ callsign: 'Neo', name: 'Thomas', - color: '#00ff00', - attributes: { sponsor: 'Acme' } + color: '#00ff00' }), 'tok' ); await screen.findByText('Neo'); }); - it('removes an attribute row so it is omitted from the create body', async () => { - const listPilotsImpl = vi.fn(async () => []); - const createPilotImpl = vi.fn(async () => ACE); - const { session } = makeTestSession({ noEnter: true, listPilotsImpl, createPilotImpl }); - const { component } = render(PilotManager, { session }); - await screen.findByText(/No pilots/i); - await openAdd(component as unknown as { openAdd: () => void }); - - await fireEvent.input(screen.getByLabelText('Callsign'), { target: { value: 'Ace' } }); - await fireEvent.click(screen.getByRole('button', { name: '+ Add attribute' })); - await fireEvent.input(screen.getByLabelText('Attribute 1 key'), { target: { value: 'bib' } }); - await fireEvent.input(screen.getByLabelText('Attribute 1 value'), { target: { value: '7' } }); - // Remove it again. - await fireEvent.click(screen.getByRole('button', { name: 'Remove attribute 1' })); - await fireEvent.click(screen.getByRole('button', { name: 'Add pilot' })); - - await waitFor(() => expect(createPilotImpl).toHaveBeenCalledTimes(1)); - expect(createPilotImpl).toHaveBeenCalledWith( - 'http://d.local', - expect.objectContaining({ callsign: 'Ace', attributes: {} }), - 'tok' - ); - }); - it('edits a pilot and CLEARS color + country, sending null for each', async () => { const listPilotsImpl = vi.fn(async () => [ACE]); const updatePilotImpl = vi.fn(async () => ({ ...ACE, color: undefined, country: undefined })); diff --git a/frontend/apps/rd-console/tests/PilotsPage.test.ts b/frontend/apps/rd-console/tests/PilotsPage.test.ts index 36aae6b..142e81b 100644 --- a/frontend/apps/rd-console/tests/PilotsPage.test.ts +++ b/frontend/apps/rd-console/tests/PilotsPage.test.ts @@ -8,8 +8,8 @@ import { makeTestSession } from './support.js'; const noop = () => {}; const PILOTS: Pilot[] = [ - { id: 'p1', callsign: 'Ace', name: 'Alice', country: 'US', vtx_types: [], attributes: {} }, - { id: 'p2', callsign: 'Bee', vtx_types: [], attributes: {} } + { id: 'p1', callsign: 'Ace', name: 'Alice', country: 'US', vtx_types: [] }, + { id: 'p2', callsign: 'Bee', vtx_types: [] } ]; describe('PilotsPage (#74) — hosts the shared PilotManager', () => { diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts index 4a3c54f..9b52d6d 100644 --- a/frontend/apps/rd-console/tests/ResultsScreen.test.ts +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -7,8 +7,8 @@ import { heatResult, standings, eventOutcome } from './fixtures.js'; import { makeTestSession } from './support.js'; const OPEN: Class = { id: 'c1', name: 'Open', source: 'MultiGP' }; -const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [], attributes: {} }; -const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [], attributes: {} }; +const ACE: Pilot = { id: 'p1', callsign: 'AceOne', vtx_types: [] }; +const BOLT: Pilot = { id: 'p2', callsign: 'Bolt', vtx_types: [] }; const EVENT: EventMeta = { id: 'e1', diff --git a/frontend/apps/rd-console/tests/pilots.test.ts b/frontend/apps/rd-console/tests/pilots.test.ts index d17f26b..f1f3dcd 100644 --- a/frontend/apps/rd-console/tests/pilots.test.ts +++ b/frontend/apps/rd-console/tests/pilots.test.ts @@ -3,7 +3,6 @@ import type { Pilot } from '@gridfpv/types'; import { buildCreateRequest, buildUpdateRequest, - cleanAttributes, emptyForm, formFromPilot, normalizeVtxTypes, @@ -22,8 +21,7 @@ const FULL: Pilot = { country: 'US', vtx_types: ['Analog', 'DJI'], multigp_id: 'mg-1', - velocidrone_id: 'vd-1', - attributes: { bib: '7' } + velocidrone_id: 'vd-1' }; describe('buildCreateRequest', () => { @@ -33,27 +31,18 @@ describe('buildCreateRequest', () => { v.name = 'Alice'; v.country = 'us'; // lower-case input → uppercased const req = buildCreateRequest(v); - // vtx_types + attributes always go (both default-empty server-side). + // vtx_types always goes (defaults empty server-side). expect(req).toEqual({ callsign: 'Ace', name: 'Alice', country: 'US', - vtx_types: [], - attributes: {} + vtx_types: [] }); // Untouched optionals are absent (not empty strings). expect(req).not.toHaveProperty('team'); expect(req).not.toHaveProperty('color'); }); - it('carries the cleaned attributes bag', () => { - const v = emptyForm(); - v.callsign = 'Ace'; - v.attributes = { ' license ': 'FAA-9', '': 'dropped' }; - const req = buildCreateRequest(v); - expect(req.attributes).toEqual({ license: 'FAA-9' }); - }); - it('sends the selected VTX set, deduped + in canonical order', () => { const v = emptyForm(); v.callsign = 'Ace'; @@ -136,19 +125,6 @@ describe('buildUpdateRequest — clear-via-null', () => { expect(buildUpdateRequest(FULL, same)).not.toHaveProperty('country'); }); - it('sends the full attributes map only when it differs', () => { - const unchanged = valuesFrom(FULL); - expect(buildUpdateRequest(FULL, unchanged)).not.toHaveProperty('attributes'); - - const added = valuesFrom(FULL); - added.attributes = { bib: '7', sponsor: 'Acme' }; - expect(buildUpdateRequest(FULL, added).attributes).toEqual({ bib: '7', sponsor: 'Acme' }); - - const cleared = valuesFrom(FULL); - cleared.attributes = {}; - expect(buildUpdateRequest(FULL, cleared).attributes).toEqual({}); - }); - it('replaces the callsign when changed and never clears it when blanked', () => { const changed = valuesFrom(FULL); changed.callsign = 'Neo'; @@ -159,9 +135,3 @@ describe('buildUpdateRequest — clear-via-null', () => { expect(buildUpdateRequest(FULL, blanked)).not.toHaveProperty('callsign'); }); }); - -describe('cleanAttributes', () => { - it('trims keys and drops blank ones', () => { - expect(cleanAttributes({ ' a ': '1', '': '2', b: '3' })).toEqual({ a: '1', b: '3' }); - }); -}); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index 0d04e5b..e7f42bc 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -797,7 +797,7 @@ describe('Session', () => { }); describe('pilots (#74)', () => { - const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [], attributes: {} }; + const ACE: Pilot = { id: 'p1', callsign: 'Ace', vtx_types: [] }; function pilotSession(overrides?: { listPilotsImpl?: ReturnType; @@ -830,7 +830,7 @@ describe('Session', () => { it('createPilot returns the new pilot (full-trust, no token)', async () => { const createPilotImpl = vi.fn(async () => ACE); const session = pilotSession({ createPilotImpl }); - const req: CreatePilotRequest = { callsign: 'Ace', vtx_types: [], attributes: {} }; + const req: CreatePilotRequest = { callsign: 'Ace', vtx_types: [] }; const result = await session.createPilot(req); expect(result).toEqual(ACE); expect(createPilotImpl).toHaveBeenCalledWith('http://d.local', req, undefined); @@ -843,7 +843,7 @@ describe('Session', () => { .mockResolvedValueOnce(ACE); const session = pilotSession({ createPilotImpl }); session.setTokenProvider(async () => 'tok'); - const result = await session.createPilot({ callsign: 'Ace', vtx_types: [], attributes: {} }); + const result = await session.createPilot({ callsign: 'Ace', vtx_types: [] }); expect(result).toEqual(ACE); expect(createPilotImpl).toHaveBeenCalledTimes(2); expect(createPilotImpl).toHaveBeenLastCalledWith('http://d.local', expect.anything(), 'tok'); @@ -853,7 +853,7 @@ describe('Session', () => { const createPilotImpl = vi.fn().mockRejectedValue(new Error('POST /pilots failed: HTTP 401')); const session = pilotSession({ createPilotImpl }); session.setTokenProvider(async () => undefined); - const result = await session.createPilot({ callsign: 'X', vtx_types: [], attributes: {} }); + const result = await session.createPilot({ callsign: 'X', vtx_types: [] }); expect(result).toBeUndefined(); expect(createPilotImpl).toHaveBeenCalledTimes(1); }); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index e1fd119..fcc87a4 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -590,8 +590,7 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { color: '#1188ff', country: 'gb', // normalized uppercase by the server vtx_types: ['Analog', 'HDZero'], - multigp_id: 'mgp-42', - attributes: { bib: '7', insurance: 'AMA-123' } + multigp_id: 'mgp-42' }, TOKEN ) @@ -605,7 +604,6 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { expect(created.country).toBe('GB'); // ISO alpha-2, uppercased expect(created.vtx_types).toEqual(['Analog', 'HDZero']); expect(created.multigp_id).toBe('mgp-42'); - expect(created.attributes).toEqual({ bib: '7', insurance: 'AMA-123' }); const ids = (await listPilots()).map((p) => p.id); expect(ids).toContain(created.id); @@ -615,23 +613,16 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { expect((await createPilot({ callsign: ' ' }, TOKEN)).status).toBe(400); }); - it('POST /pilots validates color (hex) / country (2-letter) / attribute keys → 400', async () => { + it('POST /pilots validates color (hex) / country (2-letter) → 400', async () => { // Bad hex color. expect((await createPilot({ callsign: 'BadColor', color: 'red' }, TOKEN)).status).toBe(400); // Bad country (not a 2-letter code). expect((await createPilot({ callsign: 'BadCountry', country: 'USA' }, TOKEN)).status).toBe(400); - // Empty attribute key. - expect( - (await createPilot({ callsign: 'BadAttr', attributes: { ' ': 'x' } }, TOKEN)).status - ).toBe(400); }); - it('PUT /pilots/{id} edits the new fields (set / clear / replace attributes)', async () => { + it('PUT /pilots/{id} edits the new fields (set / clear / leave-unchanged)', async () => { const created = ( - await createPilot( - { callsign: 'Editable', color: '#abcdef', country: 'de', attributes: { a: '1' } }, - TOKEN - ) + await createPilot({ callsign: 'Editable', color: '#abcdef', country: 'de' }, TOKEN) ).body as Pilot; const put = async (id: string, body: unknown, token?: string) => { @@ -645,16 +636,15 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { return { status: res.status, body: (await res.json().catch(() => undefined)) as unknown }; }; - // Set team/phonetic, change country (normalized), replace the attributes bag, leave color - // untouched (an absent field is left unchanged). + // Set team/phonetic, change country (normalized), leave color untouched (an absent field is + // left unchanged). const updated = ( await put( created.id, { team: 'Solo', phonetic: 'ED it uh bull', - country: 'us', // set → normalized uppercase - attributes: { b: '2', c: '3' } // full replacement of the bag + country: 'us' // set → normalized uppercase }, TOKEN ) @@ -663,11 +653,6 @@ describe('seam 11: application-level pilots + per-event roster (#74)', () => { expect(updated.phonetic).toBe('ED it uh bull'); expect(updated.country).toBe('US'); expect(updated.color).toBe('#ABCDEF'); // unchanged (absent in the body) - expect(updated.attributes).toEqual({ b: '2', c: '3' }); - - // A present empty map clears the attributes bag. - const cleared = (await put(created.id, { attributes: {} }, TOKEN)).body as Pilot; - expect(cleared.attributes).toEqual({}); // A bad-hex color on update → 400 (and leaves the pilot untouched). expect((await put(created.id, { color: 'nope' }, TOKEN)).status).toBe(400); diff --git a/frontend/e2e/pilots.spec.ts b/frontend/e2e/pilots.spec.ts index af5f356..2fa9886 100644 --- a/frontend/e2e/pilots.spec.ts +++ b/frontend/e2e/pilots.spec.ts @@ -4,10 +4,10 @@ * * A person opens the console, lands on the **home hub**, opens the **Pilots** page, and drives the * full directory CRUD against a **real** Director (open / no token, full-trust by default): **add** - * a pilot (callsign + real name + country + color + a custom attribute) and see it listed; **edit** - * it and **clear** the color and country, confirming they actually clear (the clear-via-`null` - * wiring); then **remove** it. Every step is a real click/input in headless chromium on the real - * `POST/PUT/DELETE /pilots` path — nothing mocked. + * a pilot (callsign + real name + country + color) and see it listed; **edit** it and **clear** the + * color and country, confirming they actually clear (the clear-via-`null` wiring); then **remove** + * it. Every step is a real click/input in headless chromium on the real `POST/PUT/DELETE /pilots` + * path — nothing mocked. * * Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack dump * (browser console, page errors, the Director's server log). The worker's Director is shared, so @@ -40,7 +40,7 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', timeout: 15_000 }); - // ── Add a pilot: callsign + real name + country + color + a custom attribute ─────────── + // ── Add a pilot: callsign + real name + country + color ──────────────────────────────── await page.getByRole('button', { name: '+ Add pilot' }).click(); const addForm = page.getByRole('form', { name: 'Add pilot' }); await expect(addForm).toBeVisible(); @@ -60,11 +60,6 @@ test('RD adds, edits (clearing color + country), and removes a directory pilot', await addForm.getByRole('textbox', { name: 'Search countries' }).fill('United States'); await addForm.getByRole('option', { name: /United States/ }).click(); - // A custom attribute row. - await addForm.getByRole('button', { name: '+ Add attribute' }).click(); - await addForm.getByLabel('Attribute 1 key').fill('bib'); - await addForm.getByLabel('Attribute 1 value').fill('42'); - // Submit (open Director — no token prompt). `exact` so it picks the dialog's submit, not the // page header's "+ Add pilot". await page.getByRole('button', { name: 'Add pilot', exact: true }).click(); From fa8190dd8d77fb480cd985a6a6b6580a33185052 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 23 Jun 2026 00:35:29 +0000 Subject: [PATCH 156/362] =?UTF-8?q?ux:=20rename=20"Time=20window"=20win=20?= =?UTF-8?q?condition=20to=20"Timed=20=E2=80=94=20Most=20Laps"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Timed win condition was displayed as "Timed window", a misnomer: the win is the most laps completed within a set time, not the time itself. Rename the display label to "Timed — Most Laps" everywhere it is shown (the Rounds form dropdown and the round summary chip). The underlying `Timed` enum value and the wire shape (`window_micros`) are unchanged. Also relabel the time parameter field from "Window (seconds)" to "Race time (seconds)" so it reads as the duration parameter rather than as the win condition. Update the EventRounds unit test summary assertion and add an e2e screenshot proof. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- .../rd-console/src/screens/EventRounds.svelte | 14 ++++-- .../apps/rd-console/tests/EventRounds.test.ts | 2 +- frontend/e2e/rounds-form-redesign.spec.ts | 46 ++++++++++++++++++ .../rounds-form-timed-most-laps.png | Bin 0 -> 74608 bytes 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e/screenshots/rounds-form-timed-most-laps.png diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 927a89f..dc9a164 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -758,7 +758,8 @@ // --- Read-only summaries for the list ---------------------------------------------------------- function winSummary(wc: WinCondition): string { if (typeof wc === 'string') return 'Best lap'; - if ('Timed' in wc) return `Timed · ${Math.round(wc.Timed.window_micros / 1_000_000)}s`; + if ('Timed' in wc) + return `Timed — Most Laps · ${Math.round(wc.Timed.window_micros / 1_000_000)}s`; if ('FirstToLaps' in wc) return `First to ${wc.FirstToLaps.n} laps`; if ('BestConsecutive' in wc) return `Best ${wc.BestConsecutive.n} consecutive`; return 'Best lap'; @@ -891,7 +892,7 @@
    + + {:else if winKind === 'FirstToLaps' || winKind === 'BestConsecutive'} diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 4fd2e07..7a1a75c 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -96,7 +96,7 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(within(roundsCard).queryByText('timed_qual')).toBeNull(); expect(within(roundsCard).getByText('Open')).toBeInTheDocument(); expect(within(roundsCard).getByText('From roster')).toBeInTheDocument(); - expect(within(roundsCard).getByText(/Timed · 120s/)).toBeInTheDocument(); + expect(within(roundsCard).getByText(/Timed — Most Laps · 120s/)).toBeInTheDocument(); // The round index renders. expect(within(roundsCard).getByText('1')).toBeInTheDocument(); }); diff --git a/frontend/e2e/rounds-form-redesign.spec.ts b/frontend/e2e/rounds-form-redesign.spec.ts index 50dc468..9b40cfa 100644 --- a/frontend/e2e/rounds-form-redesign.spec.ts +++ b/frontend/e2e/rounds-form-redesign.spec.ts @@ -109,3 +109,49 @@ test('the redesigned Rounds form shows the dynamic per-format fields (open pract // Clean up the shared Director's event back to empty (no round was saved). await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); }); + +test('the win-condition selector labels the Timed condition "Timed — Most Laps"', async ({ + page, + director +}) => { + const base = director.baseUrl; + const ev = `${base}/events/practice`; + const json = { headers: { 'Content-Type': 'application/json' } }; + await page.goto('/'); + await enterPractice(page); + + // Select the Open Class so a class round has an eligible class. + await openTab(page, 'Classes & Roster'); + const classBox = page + .getByRole('list', { name: 'Class directory' }) + .getByRole('listitem') + .filter({ hasText: 'Open Class' }) + .getByRole('checkbox', { name: 'Select Open Class' }); + if (!(await classBox.isChecked())) { + const classesSaved = page.waitForResponse( + (r) => /\/events\/.+\/classes$/.test(r.url()) && r.request().method() === 'PUT' + ); + await classBox.check(); + await classesSaved; + } + await expect(classBox).toBeChecked(); + + await openTab(page, 'Rounds & Heats'); + await page.getByRole('button', { name: '+ Add round' }).click(); + const form = page.getByRole('form', { name: 'Add round' }); + await expect(form).toBeVisible(); + + // A bracket format surfaces the win-condition selector. The Timed condition is a misnomer-free + // "Timed — Most Laps" (the win is most laps within the set time; the time is just the parameter). + await form.getByLabel('Format').selectOption('double_elim'); + const winSelect = form.getByLabel('Win condition'); + await expect(winSelect).toBeVisible(); + await expect(winSelect.locator('option', { hasText: 'Timed — Most Laps' })).toHaveCount(1); + await winSelect.selectOption('Timed'); + // The time parameter reads as the duration parameter, not the win condition itself. + await expect(form.getByLabel('Race time seconds')).toBeVisible(); + await form.screenshot({ path: `${SHOTS}rounds-form-timed-most-laps.png` }); + + // Clean up the shared Director's event back to empty (no round was saved). + await page.request.put(`${ev}/classes`, { ...json, data: { ids: [] } }); +}); diff --git a/frontend/e2e/screenshots/rounds-form-timed-most-laps.png b/frontend/e2e/screenshots/rounds-form-timed-most-laps.png new file mode 100644 index 0000000000000000000000000000000000000000..2dabf79d8cb437607e060f128b69fb9321b53ee4 GIT binary patch literal 74608 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 XBjz)285-|%G!56FPByhxH Date: Tue, 23 Jun 2026 01:10:06 +0000 Subject: [PATCH 157/362] docs: reconcile race-engine + add heat-lifecycle & decisions pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update race-engine.html to current reality: the six-phase heat FSM (Scheduled → Staged → Armed → Running → Unofficial → Final), the runtime-driven Armed→Running / Running→Unofficial steps with the SkipCountdown/ForceEnd overrides, Abort and Restart both resetting to Scheduled, the seven built-in formats with friendly names + the qualifying→bracket carry, the unified WinCondition catalogue (incl. "Timed — Most Laps"), penalties/DQ/heat-void, and the event-level round engine. Add heat-lifecycle.html — the heat FSM deep-dive (states, legality table, the runtime clock + start procedure + grace window, per-round config, open-practice ephemeral laps, determinism/replay). Add decisions.html — an ADR-style decision log (D1–D8) capturing the context/decision/rationale behind the six-phase lifecycle and naming, Abort/Restart→Scheduled, the console-owned start tone + logged randomized delay, ephemeral open-practice laps, win-condition-as- qualifying-metric, the 30s grace default, and the quali→bracket carry. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/decisions.html | 212 +++++++++++++++++++++++++++ docs/heat-lifecycle.html | 299 +++++++++++++++++++++++++++++++++++++++ docs/race-engine.html | 222 ++++++++++++++++++++++------- 3 files changed, 680 insertions(+), 53 deletions(-) create mode 100644 docs/decisions.html create mode 100644 docs/heat-lifecycle.html diff --git a/docs/decisions.html b/docs/decisions.html new file mode 100644 index 0000000..10ad00b --- /dev/null +++ b/docs/decisions.html @@ -0,0 +1,212 @@ + + + + + + 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)

    +
    +
    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. + Doc-vs-code note: the intent is to surface the qualifying rounds param as + "Heats per pilot" in the console; as built the round form still labels it + "Rounds" (the format schema label), and the qualifying metric + param coexists with the win condition in timed_qual. The unified + scoring model above is real; the friendlier "Heats per pilot" label is the remaining + surfacing step.
    +
    + +

    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.
    +
    + + +
    + + + + diff --git a/docs/heat-lifecycle.html b/docs/heat-lifecycle.html new file mode 100644 index 0000000..520c3f0 --- /dev/null +++ b/docs/heat-lifecycle.html @@ -0,0 +1,299 @@ + + + + + + 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. 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.
    • +
    • 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.

    +
    + +

    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/race-engine.html b/docs/race-engine.html index f0a7acf..703e629 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,71 @@

    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). 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 @@ -138,8 +171,43 @@

    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 seven formats. 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
    Qualifyingtimed_qualEach pilot flies rounds flights; the per-pilot best flight aggregates into a ranking under the round's qualifying metric.
    Round Robinround_robinrounds × heat_size; a metric (points / total-laps) aggregates within the rotation.
    ZippyQzippyqRolling / on-demand — the RD adds rounds as pilots are ready (rounds = initial rounds, default 0).
    Single Eliminationsingle_elimBracket; heat_size (default 2, head-to-head).
    Double Eliminationdouble_elimWinners + losers bracket; bracket_reset.
    Multi-Mainmulti_mainTiered A/B/C/… mains; main_size.
    Open Practiceopen_practiceOne open heat over the active channels (not pilots); no scoring (see §4).
    +
    +

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

    +

    4. Scoring & lap derivation

    @@ -154,13 +222,38 @@

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

    + 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, ZippyQ), + 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), 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(es) it is eligible for. + 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 @@ -83,9 +96,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 @@ -135,7 +150,11 @@

      Channel assignment — timer-defined, dynamically tuned

      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 - (Race Engine §5; Timer Adapters §5).
    • + (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).
    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..417db1a 100644 --- a/docs/pipeline-qualifying.html +++ b/docs/pipeline-qualifying.html @@ -38,60 +38,88 @@

    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 “Qualifying”) + or round_robin, 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 “Rounds” param + (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). 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 + timed_qual/round_robin generators; 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 / + round_robin qualifying round and score it.
    • +
    • 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 & 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 this is driven by the round's + heat win condition and the timed_qual format's + “Qualifying metric” param:

    - -

    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) — + Timed — Most Laps, First to N laps, Best lap, or Best N + consecutive. For qualifying this is typically a timed window or a best-lap / best-N + condition. 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.
    • +
    • Qualifying metric (the timed_qual format param, an enum of + best-lap / best-consecutive / most-laps, default + best-lap) — how the round's heats roll up into the per-pilot ranking. For + best-consecutive the span N defaults to 3.
    +
    +

    + Discrepancy to reconcile: the redesign intent is for the qualifying + metric to be the win condition (one field), and for the + timed_qual “Rounds” param to read as “Heats per pilot” + in the UI. As built today the engine still exposes a separate + metric enum param alongside the heat win condition, and the params are labeled + “Rounds” and “Qualifying metric” straight from the + format schema (no UI relabel). This doc tracks what shipped; collapsing the two is a follow-up. +

    +
    -

    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.
    • +
    • “Rounds” param = heats per pilot — the format's + rounds param (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.
    • +
    • 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 win condition + metric) + gives every member a qualifying position. A bracket round then seeds from it with the + FromRanking rule, taking the top-N — that is the seed input the + mains are generated from.

    Sim vs IRL

    @@ -106,34 +134,37 @@

    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
    +
    As built: a round targeting one class; carries a heat win condition, + a rounds param (heats per pilot, default 3), and (for timed_qual) a + metric enum (best-lap / best-consecutive / most-laps). 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. Collapse metric into win condition. The redesign intent — one field + where the qualifying metric is the win condition, and the “Rounds” param + reads as “Heats per pilot” — is not yet done; the engine still + exposes a separate metric param. A follow-up.
    6. +
    7. 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).
    8. Holeshot handling. Whether the holeshot / first partial lap is excluded from the metric (consistent with how the engine treats it).
    9. -
    10. Import + live interaction. Can a class import a ranking and then - also fly (override / merge), or is it one or the other?
    11. -
    12. Solo vs heats. Supporting both solo time-trial and heat-based - timed qualifying, and how each is scheduled.
    13. +
    14. Import + live interaction (deferred). Import / skip seeds and + whether a class can import then also fly — deferred with the import path (post-v0.4).
    15. 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-setup.html b/docs/pipeline-setup.html index 4a7cfe9..967910e 100644 --- a/docs/pipeline-setup.html +++ b/docs/pipeline-setup.html @@ -85,11 +85,14 @@

    Define the event

    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 (planned, built last). Make the common path - effortless (a table stake against the incumbents) while leaving every choice editable - afterward. In the redesign the wizard is a thin guided first pass over the event - workspace's stage-pages (Classes → Roster → Rounds+Heats → …), not a separate setup - screen — so it is built last (v0.4 Slice 7), after the pages it walks.
  • +
  • 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 — roster = per-class membership = presence

    @@ -143,15 +146,21 @@

    Channels — defined on the timer, dynamically tuned

    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, not re-declared per event.
  • -
  • The timer's node/slot count caps the field per heat — you can assign at - most as many pilots to a heat as the timer has nodes.
  • + 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-heat channel assignment (engine first-fit + manual) and the dynamic - tuning of the timer's nodes at race time are detailed in - Mains and Race Engine §5: the - engine decides who flies on which channel, and the adapter applies the tuning to the hardware. + 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

    @@ -191,9 +200,11 @@

    Entities & data implied

    Track
    one per event — a physical layout reference (IRL) or a Velocidrone spec track + id (sim).
    Timer (app registry) & its channels
    -
    the selected timer defines the channels (a standard catalog + the adapter's fixed/flexible - capability + custom raw-MHz) and the node/slot count that caps a heat; channels live in the - timer config, not an event-level pool.
    +
    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

    diff --git a/docs/timer-adapters.html b/docs/timer-adapters.html index 9b8a1b6..96a8815 100644 --- a/docs/timer-adapters.html +++ b/docs/timer-adapters.html @@ -215,7 +215,9 @@

    5. The adapter interface & capabilities

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

    @@ -271,6 +273,23 @@

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

    +
    RotorHazard
    Socket.IO / RHAPI; the full-signal case, with calibration and signal-based recovery. @@ -289,7 +308,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
    From d2b29bcdd02d1685cec2176f606af3097f4fa7d5 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 23 Jun 2026 01:10:51 +0000 Subject: [PATCH 159/362] docs: reconcile architecture + protocol; add live-state/clock deep-dive Update the internal design docs to match the as-built server: - protocol.html: replace the endpoint table with the event-rooted /events/{eventId}/... route inventory (root lifecycle/directory/static surface + per-event read/realtime/control surface); refresh the "deferred in v0.4" callout (registration binding + LiveRaceState race clock are built, splits remain); document that the resume cursor is the log length while the per-stream sequence counts from 1; cross-link the new live-state/clock page; note timer channel capability/node_count/ available_channels and the typed-only pilot fields (no attributes bag). - architecture.html: contract types are generated across gridfpv-events/ projection/engine/server (not a forward-looking note); mark protocol message shapes built; resolve the race-clock half of "Time & clock authority" (server-time authoritative); cross-link the new page. - live-state-clock.html (new): the LiveRaceState fold, the server-time race clock anchored to transition recorded_at at the append choke point, and the open-practice laps-only overlay over the log-authoritative base. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/architecture.html | 36 ++-- docs/live-state-clock.html | 329 +++++++++++++++++++++++++++++++++++++ docs/protocol.html | 107 +++++++++--- 3 files changed, 433 insertions(+), 39 deletions(-) create mode 100644 docs/live-state-clock.html diff --git a/docs/architecture.html b/docs/architecture.html index c7a68c2..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 @@ -456,13 +458,13 @@

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

    @@ -644,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/live-state-clock.html b/docs/live-state-clock.html new file mode 100644 index 0000000..17b431f --- /dev/null +++ b/docs/live-state-clock.html @@ -0,0 +1,329 @@ + + + + + + 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).
    +
    +
    +

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

    +
    +

    + 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/protocol.html b/docs/protocol.html index 65b4c49..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 → - Finished → Scored, see Race Engine §2), the active - pilots and their live lap/split progress, the running order, and the on-deck heat. The - latency-sensitive core every overlay and spectator watches.
    +
    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.
    @@ -93,12 +98,15 @@

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

    - Deferred in v0.4. Several inputs the contract is designed around are not - yet wired: the registration-binding event (#60), - the event-setup commands (#61), and the LiveRaceState race-start clock plus - per-gate splits (#62) are still to come. And the Tauri shell + 3-OS single-binary - packaging are not yet built (#57/#58) — v0.4 ships the RD console as a web app - served by the Director, not as a native window. + 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.

    @@ -135,30 +143,57 @@

    2. The snapshot read model

    apart.

    -

    Endpoint surface (as built, v0.4)

    +

    Endpoint surface (as built)

    - The concrete HTTP/WS surface the Director serves today, behind the storage trait: + 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 /snapshot/event/{event}event snapshot
    GET /snapshot/class/{event}/{class}class snapshot
    GET /snapshot/heat/{heat} (+ ?projection=live|laps|result)heat snapshot
    GET /snapshot/pilot/{event}/{pilot}pilot snapshot
    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)RD-bearer-gated; CommandCommandAck
    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 /snapshot, /stream, - /control, /auth, and /health return a typed - ProtocolError 404 — not the SPA shell — so a contract miss surfaces as a - structured error rather than an HTML page a client can't parse. + 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

    @@ -202,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:

    @@ -568,9 +620,14 @@

    9. Open decisions

  • 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. As built (v0.4): - a per-stream u64 Cursor counting from 1, distinct from the log - offset.
  • + 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 From f0e8a8e5a4df8b311c0c6b7276b9191f087cbdea Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 23 Jun 2026 01:13:49 +0000 Subject: [PATCH 160/362] =?UTF-8?q?ux(rounds):=20win=20condition=20is=20th?= =?UTF-8?q?e=20qualifying=20metric,=20rename=20rounds=E2=86=92Heats=20per?= =?UTF-8?q?=20pilot,=20heat=20names=20"=20Heat=20N"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related Rounds/heats fixes from maintainer feedback: Fix 1 — the qualifying metric IS the win condition. The separate "qualifying metric" / "ranking metric" param is removed from the timed_qual / round_robin schemas; round_engine's format_config now derives the generator's `metric` from the round's win_condition (the single source of truth) via qual_metric_for: - timed_qual: BestLap→best-lap, BestConsecutive→best-consecutive, Timed(Most Laps)→most-laps, FirstToLaps→best-lap (not a qual metric). - round_robin: Timed(Most Laps)→total-laps, everything else→points. The form's win-condition dropdown for a qualifying format offers only the qualifying conditions (Best lap, Best N consecutive, Timed — Most Laps); First to N laps is hidden, and there is no separate metric field. Fix 2 — the `rounds` param is relabeled "Heats per pilot" (key/default/behaviour unchanged) on timed_qual and round_robin, so it no longer clashes with the Round entity. Fix 3 — heat display names: open-practice → "Open Practice Heat" (unchanged); every other round → " Heat " by the heat's 1-based position in the round's heat list (e.g. "Qualifying Heat 1", "Qualifying Heat 2"). Gates: cargo xtask ci (fmt/clippy/gen-drift/tests), cargo build -p gridfpv-app --features live, frontend build/check/lint/test/contract, and e2e (27 passed) all green. New tests cover the metric derivation, the form behaviour, and the heat naming; screenshots in frontend/e2e/screenshots/. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- crates/engine/src/format.rs | 39 +++-- crates/server/src/round_engine.rs | 161 ++++++++++++++++++ frontend/apps/rd-console/src/lib/formats.ts | 14 ++ .../rd-console/src/screens/EventRounds.svelte | 55 +++++- .../apps/rd-console/tests/EventRounds.test.ts | 116 +++++++++++-- frontend/contract/events.contract.ts | 13 +- frontend/e2e/rounds-form-redesign.spec.ts | 93 ++++++++++ frontend/e2e/rounds.spec.ts | 22 ++- .../e2e/screenshots/rounds-form-bracket.png | Bin 74433 -> 75744 bytes .../screenshots/rounds-form-open-practice.png | Bin 69853 -> 70203 bytes .../screenshots/rounds-form-qualifying.png | Bin 0 -> 79927 bytes .../rounds-form-timed-most-laps.png | Bin 74608 -> 75981 bytes .../rounds-qualifying-heat-names.png | Bin 0 -> 33188 bytes 13 files changed, 461 insertions(+), 52 deletions(-) create mode 100644 frontend/e2e/screenshots/rounds-form-qualifying.png create mode 100644 frontend/e2e/screenshots/rounds-qualifying-heat-names.png diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index df3f6ec..1fa8e72 100644 --- a/crates/engine/src/format.rs +++ b/crates/engine/src/format.rs @@ -402,6 +402,12 @@ impl FormatParam { } /// 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(), @@ -500,8 +506,11 @@ impl FormatRegistry { /// 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` (number, 3), `metric` (enum: best-lap/best-consecutive/most-laps). - /// - `round_robin`: `rounds` (3), `heat_size` (4), `metric` (enum: points/total-laps). + /// - `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. + /// - `round_robin`: `rounds` ("Heats per pilot", 3), `heat_size` (4). The ranking metric is + /// likewise **derived from the win condition**, not a separate param. /// - `single_elim`: `heat_size` (number, 2). /// - `double_elim`: `bracket_reset` (bool, on). /// - `multi_main`: `main_size` (number, 4). @@ -528,15 +537,13 @@ impl FormatRegistry { }, FormatSchema { name: "round_robin".into(), + // No `metric` param: the cross-round ranking 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 (their best result ranks). params: vec![ - FormatParam::number("rounds", "Rounds", "3"), + FormatParam::number("rounds", "Heats per pilot", "3"), FormatParam::number("heat_size", "Heat size", "4"), - FormatParam::enumerated( - "metric", - "Ranking metric", - &["points", "total-laps"], - "points", - ), ], }, FormatSchema { @@ -545,15 +552,11 @@ impl FormatRegistry { }, FormatSchema { name: "timed_qual".into(), - params: vec![ - FormatParam::number("rounds", "Rounds", "3"), - FormatParam::enumerated( - "metric", - "Qualifying metric", - &["best-lap", "best-consecutive", "most-laps"], - "best-lap", - ), - ], + // 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")], }, FormatSchema { name: "zippyq".into(), diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 43c7451..3a640c9 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -359,12 +359,61 @@ pub fn is_open_practice(round: &RoundDef) -> bool { /// 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`](gridfpv_engine::round_robin::RrMetric)): +/// - [`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). /// @@ -1299,6 +1348,118 @@ mod tests { )); } + // --- 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); + } + /// 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 { diff --git a/frontend/apps/rd-console/src/lib/formats.ts b/frontend/apps/rd-console/src/lib/formats.ts index 6f82276..ad84378 100644 --- a/frontend/apps/rd-console/src/lib/formats.ts +++ b/frontend/apps/rd-console/src/lib/formats.ts @@ -16,6 +16,20 @@ /** The casual open-practice format key — a channel-seated, scoring-free practice run. */ export const OPEN_PRACTICE = 'open_practice'; +/** + * The **qualifying** format keys — `timed_qual` and `round_robin`. For these the cross-round + * ranking metric *is* the win condition (the qualifying metric is derived from the win condition, + * not a separate stored param — Rounds form redesign), so the win-condition dropdown offers only + * the qualifying-applicable conditions (Best lap, Best N consecutive, Timed — Most Laps) and the + * separate metric field is gone. + */ +export const QUALIFYING_FORMATS: readonly string[] = ['timed_qual', 'round_robin']; + +/** Whether a format key is a qualifying format (its win condition drives the qualifying metric). */ +export function isQualifyingFormat(format: string | undefined | null): boolean { + return !!format && QUALIFYING_FORMATS.includes(format); +} + /** * The friendly, human-readable label for each known format key (the engine's * `FormatRegistry::standard` names). The form selector, the Rounds list, and the Heats area all diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index dc9a164..58545ae 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -44,7 +44,12 @@ } from '@gridfpv/types'; import { channelLabel, nodeChannelLabel } from '../lib/channels.js'; import { collapseStore } from '../lib/collapse.svelte.js'; - import { fieldsForFormat, formatLabel, OPEN_PRACTICE } from '../lib/formats.js'; + import { + fieldsForFormat, + formatLabel, + isQualifyingFormat, + OPEN_PRACTICE + } from '../lib/formats.js'; import { advanceRoundLabel, advanceRoundReq, bracketTopNDefault } from '../lib/standings.js'; import type { Session } from '../lib/session.svelte.js'; @@ -187,13 +192,21 @@ // controls for it and shows the practice heat as ready to Start. const isOpenPracticeRound = (round: RoundDef): boolean => round.format === OPEN_PRACTICE; - // The display name for a heat in the Heats list. An open-practice round auto-creates a single - // heat (its lineup = the active channels); it reads better as "Open Practice Heat" than the - // generated id. Heats carry no backend label, so this is derived from the round's format — - // every other round shows the heat's own id. + // The display name for a heat in the Heats list. Heats carry no backend label, so the name is + // derived from the round + the heat's position within that round: + // - an open-practice round auto-creates a single channel heat → "Open Practice Heat"; + // - every other round → " Heat ", where N is the heat's 1-based position in the + // round's heat list (a Qualifying round → "Qualifying Heat 1", "Qualifying Heat 2", …). + // This reads far better than the generated heat id, and matches how an RD thinks of the round's + // heats ("Qualifying Heat 2") rather than the engine's internal id. const OPEN_PRACTICE_HEAT_NAME = 'Open Practice Heat'; function heatDisplayName(round: RoundDef, h: HeatSummary): string { - return isOpenPracticeRound(round) ? OPEN_PRACTICE_HEAT_NAME : h.heat; + if (isOpenPracticeRound(round)) return OPEN_PRACTICE_HEAT_NAME; + // 1-based position of this heat within the round's heats (by their order in the list). + const heatsInRound = heatsByRound(round.id); + const index = heatsInRound.findIndex((x) => x.heat === h.heat); + const n = index >= 0 ? index + 1 : heatsInRound.length + 1; + return `${round.label} Heat ${n}`; } // A heat's per-pilot channel assignment, resolved to a band+channel label (race redesign Slice @@ -457,6 +470,12 @@ // Open-practice format (open-practice Slice 2): submittable on a label + at least one active // channel (no classes). const isOpenPractice = $derived(format === OPEN_PRACTICE); + // A **qualifying** format (timed_qual / round_robin): the cross-round ranking metric *is* the win + // condition (the qualifying metric is derived from the win condition, not a separate field — + // Rounds form redesign). So the win-condition dropdown offers only the qualifying-applicable + // conditions (Best lap, Best N consecutive, Timed — Most Laps); First-to-N-laps is not a + // qualifying metric and is hidden for these formats. + const isQualifying = $derived(isQualifyingFormat(format)); const canSubmitOpenPractice = $derived( isOpenPractice && label.trim().length > 0 && selectedNodes.size > 0 ); @@ -504,6 +523,14 @@ } }); + // Keep the win condition valid for the chosen format: a qualifying format (timed_qual / + // round_robin) offers only the qualifying conditions, so if the form is sitting on First-to-N-laps + // when a qualifying format is selected, snap it to Best lap (the qualifying default). The win + // condition then drives the qualifying ranking metric (Rounds form redesign). + $effect(() => { + if (isQualifying && winKind === 'FirstToLaps') winKind = 'BestLap'; + }); + function setParamValue(key: string, value: string) { paramValues = { ...paramValues, [key]: value }; } @@ -887,13 +914,23 @@ {/if} + offer the practice **Time limit** instead. A normal round keeps its win condition. + For a **qualifying** format the win condition IS the qualifying metric, so only the + qualifying-applicable conditions are offered (First-to-N-laps is hidden) and there is no + separate "qualifying metric" field — the win condition drives the ranking. --> {#if fields.winCondition}
    - + diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 7a1a75c..a4e0a9f 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -47,15 +47,17 @@ const EVENT: EventMeta = { const FORMATS = ['double_elim', 'multi_main', 'round_robin', 'single_elim', 'timed_qual', 'zippyq']; -// The format param schemas the screen reads (`GET /formats`). `timed_qual` declares a couple of -// typed params (a number `rounds` + an enum `tiebreak`) — surfaced inline as proper labeled fields -// (Rounds form redesign item 4); the rest declare none, so they show no Format options section. +// The format param schemas the screen reads (`GET /formats`). `timed_qual` declares its `rounds` +// param labelled **"Heats per pilot"** (the qualifying-metric param is gone — the metric is derived +// from the win condition) plus a synthetic enum `tiebreak` to exercise enum-field rendering; the +// rest declare none, so they show no Format options section. Params surface inline as proper labeled +// fields (Rounds form redesign item 4). const SCHEMAS = FORMATS.map((name) => name === 'timed_qual' ? { name, params: [ - { key: 'rounds', label: 'Rounds', kind: 'number' as const, default: '3' }, + { key: 'rounds', label: 'Heats per pilot', kind: 'number' as const, default: '3' }, { key: 'tiebreak', label: 'Tiebreak', @@ -311,7 +313,7 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { // The format's params show inline as proper labeled fields, seeded from their schema defaults — // no "+ Add param" control any more (Rounds form redesign item 4). expect(screen.queryByLabelText('Add param')).not.toBeInTheDocument(); - const roundsInput = (await screen.findByLabelText('Rounds value')) as HTMLInputElement; + const roundsInput = (await screen.findByLabelText('Heats per pilot value')) as HTMLInputElement; expect(roundsInput.type).toBe('number'); expect(roundsInput.value).toBe('3'); // schema default await fireEvent.input(roundsInput, { target: { value: '5' } }); @@ -342,9 +344,11 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { // its params from the submitted request. await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); await fireEvent.change(screen.getByLabelText('Eligible class'), { target: { value: 'c1' } }); - expect(await screen.findByLabelText('Rounds value')).toBeInTheDocument(); + expect(await screen.findByLabelText('Heats per pilot value')).toBeInTheDocument(); await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'zippyq' } }); - await waitFor(() => expect(screen.queryByLabelText('Rounds value')).not.toBeInTheDocument()); + await waitFor(() => + expect(screen.queryByLabelText('Heats per pilot value')).not.toBeInTheDocument() + ); await fireEvent.click(screen.getByRole('button', { name: 'Add round' })); await waitFor(() => expect(createRoundImpl).toHaveBeenCalledTimes(1)); @@ -373,6 +377,56 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(createRoundImpl.mock.calls[0][2].channel_mode).toBe('Static'); }); + it('for a qualifying format, the win condition IS the metric — no separate metric field, no First-to-N option', async () => { + // The qualifying metric is derived from the win condition (Rounds form redesign), so a + // qualifying format (timed_qual / round_robin) shows NO separate "qualifying metric" field and + // the win-condition dropdown offers only the qualifying-applicable conditions. + const { session } = makeTestSession({ ...baseImpls(), event: { ...EVENT, rounds: [] } }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Q' } }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); + + // No separate metric field is rendered (it was the old `metric` enum param / a "metric" control). + expect(screen.queryByLabelText(/qualifying metric/i)).toBeNull(); + expect(screen.queryByLabelText(/ranking metric/i)).toBeNull(); + + // The win-condition dropdown offers only the qualifying conditions — First-to-N is hidden. + const win = (await screen.findByLabelText('Win condition')) as HTMLSelectElement; + const options = Array.from(win.options).map((o) => o.value); + expect(options).toEqual(['Timed', 'BestLap', 'BestConsecutive']); + expect(options).not.toContain('FirstToLaps'); + }); + + it('shows the First-to-N win condition for a non-qualifying (bracket) format', async () => { + // A bracket format keeps the full win-condition catalogue, including First-to-N laps. + const { session } = makeTestSession({ ...baseImpls(), event: { ...EVENT, rounds: [] } }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Mains' } }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'single_elim' } }); + + const win = (await screen.findByLabelText('Win condition')) as HTMLSelectElement; + expect(Array.from(win.options).map((o) => o.value)).toContain('FirstToLaps'); + }); + + it('shows the rounds param as "Heats per pilot" for a qualifying format', async () => { + // The `rounds` param keeps its key/default/behaviour but is relabeled "Heats per pilot" so it no + // longer clashes with the Round entity (Rounds form redesign). + const { session } = makeTestSession({ ...baseImpls(), event: { ...EVENT, rounds: [] } }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Q' } }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); + + // The field renders as "Heats per pilot" (its value input is labelled "
  • +
  • + +
    + 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,6 +131,14 @@

    Reference

    to the engine. The vocabulary the whole log speaks.
  • +
  • + +
    + 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. +
    +
  • @@ -130,6 +147,14 @@

    Reference

    consumes canonical events.
  • +
  • + +
    + 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. +
    +
  • @@ -137,6 +162,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. +
    +
  • From 7cf51ab14e09678dbe20575bd3631c5ea1268e4d Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 23 Jun 2026 01:22:56 +0000 Subject: [PATCH 162/362] docs: qualifying metric = win condition + "Heats per pilot" now as-built (#188) PR #188 unified the qualifying metric into the round's win condition and relabeled the qualifying passes knob to "Heats per pilot". Flip the docs that documented these as pending/intended over to as-built: - pipeline-qualifying.html: drop the "discrepancy to reconcile" and the "collapse metric into win condition" open decision; describe the win-condition-derived metric (qual_metric_for mapping) and the "Heats per pilot" param. - race-engine.html: fix the format table rows that still implied a separate metric param for timed_qual/round_robin. - decisions.html: mark D6 implemented and add the realized mapping. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ --- docs/decisions.html | 24 ++++++++---- docs/pipeline-qualifying.html | 70 ++++++++++++++++------------------- docs/race-engine.html | 4 +- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/decisions.html b/docs/decisions.html index 10ad00b..84b5cd0 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -138,7 +138,7 @@

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

    reflected because the phase is the log's.
  • -

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

    +

    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 @@ -154,13 +154,21 @@

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

    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. - Doc-vs-code note: the intent is to surface the qualifying rounds param as - "Heats per pilot" in the console; as built the round form still labels it - "Rounds" (the format schema label), and the qualifying metric - param coexists with the win condition in timed_qual. The unified - scoring model above is real; the friendlier "Heats per pilot" label is the remaining - surfacing step.
    + 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.

    D7 · The grace window defaults to a bounded 30 seconds

    diff --git a/docs/pipeline-qualifying.html b/docs/pipeline-qualifying.html index 417db1a..3ab9156 100644 --- a/docs/pipeline-qualifying.html +++ b/docs/pipeline-qualifying.html @@ -45,8 +45,9 @@

    Role

    format is timed_qual (friendly name “Qualifying”) or round_robin, 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 “Rounds” param - (default 3) sets how many — over the static, channel-balanced field, and + 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). 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 @@ -69,42 +70,37 @@

    Three ways to get a ranking

    and explicit-import seeds are planned. -

    Win condition & metric (when run live)

    +

    Win condition = qualifying metric (when run live)

    - How a pilot's flying becomes a rank. In the as-built engine this is driven by the round's - heat win condition and the timed_qual format's - “Qualifying metric” param: + 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:

      -
    • Win condition (the round form's Win condition dropdown) — - Timed — Most Laps, First to N laps, Best lap, or Best N - consecutive. For qualifying this is typically a timed window or a best-lap / best-N - condition. 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.
    • -
    • Qualifying metric (the timed_qual format param, an enum of - best-lap / best-consecutive / most-laps, default - best-lap) — how the round's heats roll up into the per-pilot ranking. For - best-consecutive the span N defaults to 3.
    • +
    • 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: Timed → total-laps, otherwise + a points standing. For best-consecutive the span N defaults to 3.
    -
    -

    - Discrepancy to reconcile: the redesign intent is for the qualifying - metric to be the win condition (one field), and for the - timed_qual “Rounds” param to read as “Heats per pilot” - in the UI. As built today the engine still exposes a separate - metric enum param alongside the heat win condition, and the params are labeled - “Rounds” and “Qualifying metric” straight from the - format schema (no UI relabel). This doc tracks what shipped; collapsing the two is a follow-up. -

    -

    Organizing qualifying flights — multiple heats per pilot

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

    -

    Post-1.0 — GridFPV native timer-server (future)

    -

    Goal: own the server half of the timing stack. (Timer Adapters, D15)

    +

    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 Tier-2 remote-controlled GridFPV timer-server on the - same Pi + RX5808 hardware that reuses the RH node firmware - (RX5808 driver + node-level detection) and strips the RH RMS — a software swap, - not new hardware. Gives the dense signal natively (solves the marshaling - fidelity gap) and enables full re-detection marshaling; the mock-timer protocol is its - wire-protocol first draft.
    • +
    • 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.

    - Tier 1 stays primary throughout: the RH adapter remains the non-negotiable - default — GridFPV is never gated on a GridFPV timer. Sequenced after the core RMS + - marshaling are proven; post-v1.0.0, not a near-term dependency - (D15, #238). + 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

    diff --git a/docs/timer-adapters.html b/docs/timer-adapters.html index 96a8815..e172608 100644 --- a/docs/timer-adapters.html +++ b/docs/timer-adapters.html @@ -290,6 +290,28 @@

    8. Other sources (sketch)

    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. From 9ed52c59fcfd5ff200e0fd989f2af66ac712e1ca Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 26 Jun 2026 23:34:24 +0000 Subject: [PATCH 237/362] docs(rotorhazard): plugin design + sliced build plan (D16) The required in-process RH integration that supersedes the socket-API path. Verified RHAPI surface (floor RHAPI 1.3 / RH v4.3.0+, current 1.4 / v4.4.0): socket_listen/socket_broadcast channel on RH's own socket.io under a gridfpv_* namespace (reuses the adapter's existing connection), real race.stage()/stop(), live dense RSSI via interface.seats Node history, and frequencyset_alter for calibration. #3 recalc is a re-detection over the stored dense trace, not a hardware call (detection-fidelity flagged as the load-bearing S4 risk). Five slices: S0 RH-4.4 mock-node dev harness -> S1 skeleton + gridfpv_hello handshake + guided-install UX -> S2 live dense RSSI (kills save-then-pull) -> S3 clean start/stop + per-node passes (deletes alter_race_format/seating/prestage hacks) -> S4 draggable-threshold recalc. Wired into the docs index. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/index.html | 9 + docs/rotorhazard-plugin.html | 342 +++++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 docs/rotorhazard-plugin.html diff --git a/docs/index.html b/docs/index.html index 5d53710..2093e02 100644 --- a/docs/index.html +++ b/docs/index.html @@ -131,6 +131,15 @@

    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). +
    +
  • diff --git a/docs/rotorhazard-plugin.html b/docs/rotorhazard-plugin.html new file mode 100644 index 0000000..126bdc7 --- /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 prestageplugin controls start via stage()/schedule()S3
    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. +
    + + +
    + + + + From fe95615a7455da3d29c59a1fb18bb1b6c1b6e221 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 00:26:23 +0000 Subject: [PATCH 238/362] feat(rotorhazard): RH v4.4.0 mock-node plugin dev harness (plugin S0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice S0 of the GridFPV RotorHazard plugin (D16, docs/rotorhazard-plugin.html): the current-RH dev harness that unblocks every later slice. No plugin logic yet — just a loadable placeholder and the harness to iterate on it in-container. - docker/rotorhazard: build the image from RotorHazard **v4.4.0 source** (replacing the stale cruwaller/rotorhazard:latest = 4.0.0-beta.4, below the RHAPI 1.3 / RH 4.3.0+ floor). Mock nodes + emulated mock_data CSV. Two 4.4.0 specifics: * config.json sets GENERAL.MOCK_NODE_SIGNAL=1 — new in 4.4.0, defaults to 0 (=no signal); 1 reads mock_data_{N}.csv (the emulated-signal path the harness needs). * run with --data=src/server so DATA_DIR=PROGRAM_DIR (mock CSVs + user plugins/ resolve in one place; else 4.4.0 chdir's to ~/rh-data and mounts miss). Image boots QUIET by default (no baked CSV, no auto-tune) so simulate_lap / multi-node live tests aren't polluted; the self-demo signal (auto-tune a CSV-backed node) is gated behind GRIDFPV_RH_DEMO=1, set only by docker-compose. - plugins/gridfpv: placeholder plugin (empty initialize(rhapi) + manifest.json with required_rhapi_version 1.3). Loads cleanly into the 4.4.0 container (S1 adds the gridfpv_hello handshake). - testkit RhContainer: point at the locally-built gridfpv-rotorhazard:4.4.0, auto-build it if missing (keeps `cargo xtask live` one command), and mount a plugin dir (GRIDFPV_RH_PLUGIN / start_with_plugin). - xtask: `cargo xtask live` boots every live RH against the plugin; `rh-mock feed --plugin` and a new `rh-mock plugin-check` (boot RH + plugin, assert it loads). - adapter fix (4.4.0 wire change): current_laps `lap_time` went string -> number (pretty string moved to lap_time_formatted; source/deleted now inline). RawLap typed lap_time as Option, which failed the whole snapshot deserialize -> zero laps. Parse it permissively (serde_json::Value, advisory/unused) and skip `deleted` laps. Adapter reads only lap_number + lap_time_stamp (both stable). Old golden fixture still parses. Gates: cargo xtask ci green; cargo xtask rh-mock plugin-check PASS; all RH-relevant live targets green on 4.4.0 (rh_live, rh_signal, rh_connect, engine e2e). Pre-existing, unrelated: gridfpv-server's full_event_live live test doesn't compile (issue #72 router(AppState)->router(EventRegistry) drift; local-only, not in CI) — untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapters/src/rotorhazard.rs | 37 +- crates/testkit/src/lib.rs | 114 ++++- docker/rotorhazard/Dockerfile | 69 +++ docker/rotorhazard/README.md | 127 ++++-- docker/rotorhazard/config.json | 5 + docker/rotorhazard/docker-compose.yml | 37 +- docker/rotorhazard/mock-entrypoint.sh | 60 +++ docker/rotorhazard/mock_data_1.csv | 600 ++++++++++++++++++++++++++ plugins/gridfpv/README.md | 32 ++ plugins/gridfpv/__init__.py | 28 ++ plugins/gridfpv/manifest.json | 13 + xtask/src/main.rs | 15 +- xtask/src/rh_mock.rs | 115 ++++- 13 files changed, 1184 insertions(+), 68 deletions(-) create mode 100644 docker/rotorhazard/Dockerfile create mode 100644 docker/rotorhazard/config.json create mode 100644 docker/rotorhazard/mock-entrypoint.sh create mode 100644 docker/rotorhazard/mock_data_1.csv create mode 100644 plugins/gridfpv/README.md create mode 100644 plugins/gridfpv/__init__.py create mode 100644 plugins/gridfpv/manifest.json diff --git a/crates/adapters/src/rotorhazard.rs b/crates/adapters/src/rotorhazard.rs index aeb15a5..7386566 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); @@ -226,17 +228,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. @@ -696,6 +709,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) @@ -1122,6 +1140,7 @@ mod tests { lap_time: None, lap_time_stamp, late_lap: false, + deleted: None, } } diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 864f983..d15abbf 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -705,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 { @@ -717,7 +736,29 @@ 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 `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. Used by + /// `cargo xtask rh-mock` (which can't set process env under `#![forbid(unsafe_code)]`). + pub fn start_with_plugin( + port: u16, + update_interval: &str, + csvs: &[(usize, String)], + plugin_dir: Option, + ) -> Self { + ensure_image(); + + let plugin_dir = plugin_dir.or_else(|| std::env::var_os(PLUGIN_ENV).map(PathBuf::from)); + let name = format!("gridfpv-rh-sig-{port}"); // Clean any leftover from a previous aborted run. let _ = Command::new("docker").args(["rm", "-f", &name]).output(); @@ -741,12 +782,29 @@ 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()); + // Optionally boot against the GridFPV plugin (S0+): mount the host plugin dir + // into the container's user `plugins/gridfpv` (read-only). The plugin is + // additive — a placeholder loads cleanly and stock-RH behavior is unchanged. + if let Some(plugin_dir) = plugin_dir { + if plugin_dir.is_dir() { + args.push("-v".into()); + args.push(format!( + "{}:{RH_SERVER_DIR}/plugins/gridfpv:ro", + plugin_dir.display() + )); + } else { + eprintln!( + "{PLUGIN_ENV}={} is not a directory; booting stock RH without the plugin", + plugin_dir.display() + ); + } + } + args.push(RH_IMAGE.into()); let out = Command::new("docker") .args(&args) @@ -780,6 +838,20 @@ impl RhContainer { &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). @@ -799,6 +871,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; 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/plugins/gridfpv/README.md b/plugins/gridfpv/README.md new file mode 100644 index 0000000..6505a9f --- /dev/null +++ b/plugins/gridfpv/README.md @@ -0,0 +1,32 @@ +# GridFPV RotorHazard plugin + +The required in-process RotorHazard integration (decision **D16**). Design + +sliced build plan: [`docs/rotorhazard-plugin.html`](../../docs/rotorhazard-plugin.html). + +A RotorHazard plugin is a directory dropped into RH's user-`plugins/` dir, with an +`__init__.py` exposing `initialize(rhapi)` and a `manifest.json` declaring +`required_rhapi_version`. RH's loader imports it and hands over the `rhapi` object. + +- **Floor:** RHAPI **1.3** / RotorHazard **v4.3.0+** (declared in `manifest.json`). +- **Channel:** `gridfpv_*` events on RH's existing socket.io server (S1+). + +## Status — S0 (placeholder) + +`__init__.py` is an empty, load-only skeleton: `initialize(rhapi)` logs and returns. +S0's job is the **dev harness**, not plugin logic — see +[`docker/rotorhazard/README.md`](../../docker/rotorhazard/README.md). The harness +mounts this folder into the RH v4.4.0 container's `plugins/gridfpv/` and boots it; +RH must log the plugin as loaded with no `load_issue`. Confirm with: + +```sh +cargo xtask rh-mock plugin-check +``` + +## Roadmap + +| Slice | Adds | +|-------|------| +| S1 | `gridfpv_hello` handshake via `socket_listen`; capabilities/version | +| S2 | live dense RSSI broadcast (`gridfpv_signal`) — retires save-then-pull | +| S3 | clean start/stop (`race.stage()`/`stop()`) + per-node passes | +| S4 | threshold recalculate (#3) over the stored dense trace + `frequencyset_alter` | diff --git a/plugins/gridfpv/__init__.py b/plugins/gridfpv/__init__.py new file mode 100644 index 0000000..6e3f11b --- /dev/null +++ b/plugins/gridfpv/__init__.py @@ -0,0 +1,28 @@ +"""GridFPV RotorHazard plugin — S0 placeholder. + +This is the in-process RH integration described in ``docs/rotorhazard-plugin.html`` +(decision D16). At S0 it is an **empty, load-only skeleton**: RH's plugin loader +imports this module and calls :func:`initialize`, which currently does nothing. Its +sole job at S0 is to prove the loader path end-to-end inside the RH v4.4.0 dev +harness — RH logs the plugin as loaded with no ``load_issue``. + +The real handshake (``gridfpv_hello`` over ``rhapi.ui.socket_listen``), live dense +RSSI, clean start/stop, and the threshold recalculate all arrive in later slices +(S1–S4). Nothing here taps RHAPI yet. + +Floor: RHAPI 1.3 / RotorHazard v4.3.0+ (declared in ``manifest.json``). +""" + +import logging + +logger = logging.getLogger(__name__) + + +def initialize(rhapi): + """RH plugin entry point — called once by the loader with the ``rhapi`` object. + + S0 placeholder: log that we loaded, then return. No event hooks, no socket + handlers, no RHAPI calls yet (those land in S1+). ``rhapi`` is accepted and + intentionally unused so the signature matches what the loader invokes. + """ + logger.info("GridFPV plugin loaded (S0 placeholder — no handlers registered yet)") diff --git a/plugins/gridfpv/manifest.json b/plugins/gridfpv/manifest.json new file mode 100644 index 0000000..002262b --- /dev/null +++ b/plugins/gridfpv/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "GridFPV", + "author": "GridFPV", + "author_uri": "https://github.com/GridFPV", + "description": "GridFPV race-director integration: live dense RSSI, clean start/stop, and threshold recalculate over RH's socket.io (gridfpv_* namespace).", + "info_uri": "https://github.com/GridFPV/gridfpv", + "license": "MIT", + "license_uri": "https://github.com/GridFPV/gridfpv/blob/main/LICENSE", + "version": "0.0.0", + "required_rhapi_version": "1.3", + "update_uri": null, + "text_domain": null +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7ca5aa3..5f74d45 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -138,6 +138,15 @@ fn gen_check() -> bool { /// up and tears down its own disposable RotorHazard, so no external state is needed — /// just Docker. `--include-ignored` runs the `#[ignore]`d container tests too. fn live() -> bool { + // Boot every live RotorHazard against the GridFPV plugin (S0+): the testkit's + // RhContainer mounts the dir named by `GRIDFPV_RH_PLUGIN` into the container's + // user `plugins/gridfpv`. The plugin is additive — at S0 it's a load-only + // placeholder, so the socket-path live tests behave identically — and this lets + // later slices iterate on the plugin in-container under the live suite. Set on the + // child `cargo test` process (env, not a process-global set_var, which is unsafe + // under this crate's `#![forbid(unsafe_code)]`). + let plugin_dir = workspace_root().join("plugins/gridfpv"); + // Run each target sequentially so at most one RotorHazard container exists at a // time (cargo runs separate test binaries in parallel otherwise). let target = |package: &str, name: &str, ignored: bool| { @@ -155,7 +164,11 @@ fn live() -> bool { if ignored { args.push("--ignored"); } - run("cargo", &args) + run_env( + "cargo", + &args, + &[(gridfpv_testkit::PLUGIN_ENV, &plugin_dir)], + ) }; // No container needed (in-process mock WS server). let ws = target("gridfpv-adapters", "velocidrone_ws", false); diff --git a/xtask/src/rh_mock.rs b/xtask/src/rh_mock.rs index 89098ba..ad34190 100644 --- a/xtask/src/rh_mock.rs +++ b/xtask/src/rh_mock.rs @@ -43,6 +43,7 @@ pub fn run(args: &[String]) -> bool { match args.first().map(String::as_str) { Some("feed") => feed(&args[1..]), Some("dump") => dump(&args[1..]), + Some("plugin-check") => plugin_check(&args[1..]), Some("list") | None => { print_menu(); true @@ -57,16 +58,28 @@ pub fn run(args: &[String]) -> bool { fn usage() { eprintln!( - "\nusage: cargo xtask rh-mock \n\ + "\nusage: cargo xtask rh-mock \n\ \n\ - feed [scenario] [--port P] [--tick T] spin up a real RotorHazard fed a mock signal\n\ + feed [scenario] [--port P] [--tick T] [--plugin]\n\ + \x20 spin up a real RotorHazard fed a mock signal\n\ + \x20 (--plugin mounts the GridFPV plugin too)\n\ dump print the captured ?projection=signal values\n\ + plugin-check [--port P] boot RH with the GridFPV plugin and confirm it loads\n\ list show the scenario menu\n\ \n\ Run `cargo xtask rh-mock list` for the scenarios." ); } +/// The in-repo GridFPV plugin directory (`/plugins/gridfpv`) the harness mounts +/// into RH's user `plugins/`. `CARGO_MANIFEST_DIR` is `/xtask`. +fn plugin_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest dir has a parent (workspace root)") + .join("plugins/gridfpv") +} + // --------------------------------------------------------------------------------------------- // Scenario menu — maps a CLI name onto a testkit CSV set (one or more `(node_index, csv)` pairs). // --------------------------------------------------------------------------------------------- @@ -168,6 +181,7 @@ fn feed(args: &[String]) -> bool { let mut scenario_name = "clean".to_string(); let mut port = DEFAULT_PORT; let mut tick = DEFAULT_TICK.to_string(); + let mut with_plugin = false; let mut it = args.iter(); while let Some(arg) = it.next() { @@ -186,6 +200,7 @@ fn feed(args: &[String]) -> bool { return false; } }, + "--plugin" => with_plugin = true, other if other.starts_with("--") => { eprintln!("unknown flag: {other}"); return false; @@ -211,15 +226,20 @@ fn feed(args: &[String]) -> bool { let csvs = (scenario.build)(); let node_count = csvs.len(); + let plugin = with_plugin.then(plugin_dir); println!( "\n\x1b[1mStarting RotorHazard\x1b[0m with scenario \x1b[1m{}\x1b[0m on port {port} \ - ({node_count} emulated node(s), tick={tick}s)...", - scenario.name + ({node_count} emulated node(s), tick={tick}s{})...", + scenario.name, + if with_plugin { ", +GridFPV plugin" } else { "" } ); println!(" {}", scenario.blurb); + if let Some(dir) = &plugin { + println!(" mounting GridFPV plugin from {}", dir.display()); + } // RAII: removed on drop (Ctrl-C below drops it). Blocks until the HTTP port is up. - let rh = RhContainer::start(port, &tick, &csvs); + let rh = RhContainer::start_with_plugin(port, &tick, &csvs, plugin); let url = rh.url().to_string(); println!( @@ -243,6 +263,91 @@ fn feed(args: &[String]) -> bool { } } +// --------------------------------------------------------------------------------------------- +// plugin-check: boot RH with the GridFPV plugin mounted and confirm RH's loader accepts it. +// --------------------------------------------------------------------------------------------- + +/// S0 acceptance check: build/boot the RH v4.4.0 harness image with the in-repo GridFPV +/// plugin mounted into RH's user `plugins/`, then read the RH log and assert the loader +/// reported the plugin **loaded** with no `load_issue`. Tears the container down on exit +/// (RAII). This proves the plugin-mount path end-to-end without needing a Director. +fn plugin_check(args: &[String]) -> bool { + let mut port = DEFAULT_PORT; + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--port" => match it.next().and_then(|v| v.parse::().ok()) { + Some(p) => port = p, + None => { + eprintln!("--port needs a number"); + return false; + } + }, + other => { + eprintln!("unknown arg: {other}"); + return false; + } + } + } + + if !docker_present() { + eprintln!("\x1b[31mdocker not found on PATH.\x1b[0m plugin-check drives a real container."); + return false; + } + + let dir = plugin_dir(); + if !dir.join("manifest.json").is_file() || !dir.join("__init__.py").is_file() { + eprintln!( + "\x1b[31mGridFPV plugin not found at {}\x1b[0m (expected __init__.py + manifest.json)", + dir.display() + ); + return false; + } + + println!( + "\n\x1b[1mplugin-check\x1b[0m: booting RH (port {port}) with the GridFPV plugin from {}…", + dir.display() + ); + // One mock node is enough — we only care that the loader accepts the plugin. + let csvs = vec![(0usize, node_csv(&NodeCsv::default()))]; + let rh = RhContainer::start_with_plugin(port, DEFAULT_TICK, &csvs, Some(dir)); + + // The plugin loads during RH init (before the HTTP port is fully serving); poll the + // log briefly in case init trails the port-open by a moment. + const LOADED: &str = "Loaded external plugin 'gridfpv'"; + const NOT_LOADED: &str = "Plugin 'gridfpv' not loaded"; + let mut logs = String::new(); + for _ in 0..20 { + logs = rh.logs(); + if logs.contains(LOADED) || logs.contains(NOT_LOADED) { + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + + let loaded = logs.contains(LOADED); + let failed = logs.contains(NOT_LOADED); + println!("\n\x1b[1mRH plugin log lines:\x1b[0m"); + for line in logs + .lines() + .filter(|l| l.contains("gridfpv") || l.contains("GridFPV")) + { + println!(" {line}"); + } + + if loaded && !failed { + println!("\n\x1b[32mPASS\x1b[0m — RH loaded the GridFPV plugin with no load issue."); + true + } else { + eprintln!( + "\n\x1b[31mFAIL\x1b[0m — RH did not cleanly load the GridFPV plugin \ + (loaded={loaded}, load_issue={failed}). Full log:\n{logs}" + ); + false + } + // `rh` drops here → container removed. +} + /// Print exactly what the maintainer does next: point a Director at this RH timer and run a heat. fn print_run_instructions(url: &str, node_count: usize) { let last_node = node_count.saturating_sub(1); From 5a035da467ed94ac1d79676f7814a7e794edc66a Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 00:35:39 +0000 Subject: [PATCH 239/362] test(server): migrate full_event_live to the event-rooted router (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-only `gridfpv-server` full_event_live test still drove the pre-#72 single-AppState router: `router(state)`, bare `/control` / `/stream` / `/snapshot/...` routes, and an ad-hoc `AppState::new(InMemoryLog)`. Since #72 made the protocol surface event-rooted (`router(EventRegistry)`, routes under `/events/{event}/...`), this test no longer compiled — and because the live suite isn't in CI, the rot went unnoticed. Migrate it to mirror the already-updated `tests/control.rs`: build a fresh `EventRegistry`, drive the built-in Practice event, root every route + scope under it. No behavioral change to the assertions (heat loop → live passes → protocol client → marshaling void → auth gate). Verified: compiles; `cargo test -p gridfpv-server --features live --test full_event_live -- --ignored` passes; `cargo xtask ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/server/tests/full_event_live.rs | 72 ++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/server/tests/full_event_live.rs b/crates/server/tests/full_event_live.rs index 101c6c7..fc08452 100644 --- a/crates/server/tests/full_event_live.rs +++ b/crates/server/tests/full_event_live.rs @@ -7,15 +7,18 @@ //! the path a phone / overlay takes (protocol.html §2–§5): //! //! 1. Stand up the server (`axum::serve` on an ephemeral port) over a fresh -//! [`InMemoryLog`]; the RD issues itself an RD bearer token. +//! [`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 /snapshot/...` for the initial body+cursor, then a WS -//! `/stream` subscribe `from` that cursor, and assert the client receives **in-order** +//! 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). @@ -47,10 +50,10 @@ 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_storage::InMemoryLog; use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; @@ -64,17 +67,23 @@ const RH_PORT: u16 = 5041; 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(state)` 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). -async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) { +/// 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(state); + let app = router(registry); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); @@ -90,7 +99,7 @@ async fn post_raw(addr: &str, command: &Command, token: Option<&str>) -> (u16, O .map(|t| format!("Authorization: Bearer {t}\r\n")) .unwrap_or_default(); let request = format!( - "POST /control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + "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() ); @@ -156,7 +165,9 @@ async fn pilot_snapshot_status(addr: &str, path: &str) -> u16 { /// 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}/stream")).await.unwrap(); + 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(); @@ -312,10 +323,17 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { let rh = RhContainer::start(RH_PORT, TICK, &scenario); let rh_url = rh.url().to_string(); - // --- Stand up the server over a fresh log; the RD issues itself a token. --- - let state = AppState::new(InMemoryLog::default()); - let rd = state.tokens().issue_rd_token(); - let (addr, _server) = serve(state.clone()).await; + // --- 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( @@ -335,7 +353,7 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { // === 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, "/snapshot/event/spring-cup").await; + 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:?}"), @@ -352,7 +370,7 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { &addr, &SubscribeRequest { scope: Scope::Event { - event: EventId("spring-cup".into()), + event: EventId(EVENT.into()), }, from: Some(snapshot.cursor), contract_version: None, @@ -396,7 +414,7 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { // 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, "/snapshot/event/spring-cup").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:?}"), @@ -416,7 +434,11 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { // === 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, "/snapshot/pilot/spring-cup/node-0").await; + 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!( @@ -435,12 +457,16 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { // 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, "/snapshot/pilot/spring-cup/node-0").await; + 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("spring-cup".into()), + event: EventId(EVENT.into()), pilot: PilotId("node-0".into()), }, from: Some(pilot_snap2.cursor), @@ -474,7 +500,11 @@ async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { // 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, "/snapshot/pilot/spring-cup/node-0").await, + 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)" ); From 5df42035878bd5675d232e999cd1b6797a1a327c Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 01:36:43 +0000 Subject: [PATCH 240/362] feat(rotorhazard): plugin handshake + guided install + mock-control (plugin S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice S1 of the GridFPV RotorHazard plugin (D16, docs/rotorhazard-plugin.html): the gridfpv_hello handshake, the required-with-guided-install UX, and (folded in) a test-only network mock-node control plugin. Builds on the S0 harness; the socket data path is unchanged (preferring the plugin transport for data lands in S2). Plugin (Python): - plugins/gridfpv: register gridfpv_hello via rhapi.ui.socket_listen; reply gridfpv_hello_ack {protocol/plugin/rhapi version, capabilities, node_count} to the asking client. Verified in the 4.4.0 container. - plugins/gridfpv_mock (TEST ONLY): gridfpv_mock_* socket handlers (tune / set_rssi / lap / state) driving RaceContext.interface so a test can shape the emulated signal live over the wire. Reuses the same socket_listen plumbing; never shipped to users (not in the download bundle). Grid backend (Rust): - transport.rs: emit gridfpv_hello on connect, listen for gridfpv_hello_ack, expose wait_for_plugin(timeout); PluginHello wire type; DIRECTOR_PROTOCOL_VERSION. - server timers.rs: Timer.plugin: Option (Missing/Present/Incompatible), #[ts(optional)] + serde(default) so it's additive; set_plugin; reset on load/reconfigure. - app drive(): after connect, probe + classify (protocol match => Present, mismatch => Incompatible, no answer => Missing) and set_plugin. Re-probed on every reconnect. - server: GET /plugin/gridfpv.zip serves the plugin bundle for the guided install — a dependency-free STORE-only zip built from the plugin source embedded at compile time (so the download matches the Director's protocol version). Frontend (Svelte): - A plugin status chip on the timer row; when missing/incompatible it opens a guided one-step install (download bundle, drop into plugins/, restart RH). Friendly-name only. - lib/pluginPresence.ts mapping + 6 unit tests; overlays poll-fresh plugin status. Gates: cargo xtask ci green (fmt, clippy -D, test, gen-drift); frontend svelte-check + lint + vitest green; live rh_connect_live asserts the timer reports the plugin Present after connect (plugin mounted). Handshake + mock-control verified in-container. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/PluginPresence.ts | 37 ++++ bindings/Timer.ts | 10 +- crates/adapters/src/rotorhazard/transport.rs | 88 +++++++++ crates/app/src/source/rotorhazard.rs | 43 ++++- crates/app/tests/rh_connect_live.rs | 25 ++- crates/server/src/app.rs | 25 +++ crates/server/src/lib.rs | 1 + crates/server/src/plugin_bundle.rs | 168 ++++++++++++++++++ crates/server/src/round_engine.rs | 1 + crates/server/src/timers.rs | 62 ++++++- crates/testkit/src/lib.rs | 47 +++-- .../apps/rd-console/src/lib/pluginPresence.ts | 78 ++++++++ .../src/screens/PluginCallout.svelte | 93 ++++++++++ .../src/screens/TimerManager.svelte | 10 +- .../rd-console/tests/pluginPresence.test.ts | 80 +++++++++ frontend/packages/types/src/generated.ts | 1 + plugins/gridfpv/__init__.py | 79 ++++++-- plugins/gridfpv/manifest.json | 2 +- plugins/gridfpv_mock/README.md | 34 ++++ plugins/gridfpv_mock/__init__.py | 96 ++++++++++ plugins/gridfpv_mock/manifest.json | 13 ++ xtask/src/rh_mock.rs | 50 ++++-- 22 files changed, 994 insertions(+), 49 deletions(-) create mode 100644 bindings/PluginPresence.ts create mode 100644 crates/server/src/plugin_bundle.rs create mode 100644 frontend/apps/rd-console/src/lib/pluginPresence.ts create mode 100644 frontend/apps/rd-console/src/screens/PluginCallout.svelte create mode 100644 frontend/apps/rd-console/tests/pluginPresence.test.ts create mode 100644 plugins/gridfpv_mock/README.md create mode 100644 plugins/gridfpv_mock/__init__.py create mode 100644 plugins/gridfpv_mock/manifest.json 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/Timer.ts b/bindings/Timer.ts index b27ef35..2058330 100644 --- a/bindings/Timer.ts +++ b/bindings/Timer.ts @@ -1,5 +1,6 @@ // 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"; @@ -48,4 +49,11 @@ node_count: number, * order. Empty means none configured (assignment then allocates nothing). Additive — defaults * empty for a pre-channel-model timer. */ -available_channels: Array, }; +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/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index f75db73..bb756cd 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -27,6 +27,12 @@ use std::time::{Duration, Instant}; /// 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; @@ -83,6 +89,40 @@ pub fn raw_from_socket(event: &str, payload: &Payload) -> Option { } } +/// 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 { @@ -111,6 +151,12 @@ pub struct RotorHazardConnection { /// 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 { @@ -127,6 +173,9 @@ impl RotorHazardConnection { // 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)); // `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 @@ -344,6 +393,16 @@ impl RotorHazardConnection { 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, the enter/exit @@ -355,6 +414,15 @@ impl RotorHazardConnection { 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, @@ -362,6 +430,7 @@ impl RotorHazardConnection { alive, savable_heat, current_format, + hello, }) } @@ -375,6 +444,25 @@ impl RotorHazardConnection { 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 diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index 5f16a38..6e8070c 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -49,9 +49,11 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use gridfpv_adapters::rotorhazard::RotorHazardAdapter; -use gridfpv_adapters::rotorhazard::transport::RotorHazardConnection; +use gridfpv_adapters::rotorhazard::transport::{ + DIRECTOR_PROTOCOL_VERSION, PluginHello, RotorHazardConnection, +}; use gridfpv_events::{AdapterId, CompetitorRef, Event}; -use gridfpv_server::timers::{TimerId, TimerRegistry, TimerStatus}; +use gridfpv_server::timers::{PluginPresence, TimerId, TimerRegistry, TimerStatus}; use tokio::task::JoinHandle; use super::PassSink; @@ -88,6 +90,13 @@ const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(10); /// "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. @@ -454,6 +463,12 @@ fn drive( 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); @@ -482,6 +497,30 @@ fn drive( 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), diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index ea6f6c5..849b64a 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -52,7 +52,9 @@ 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, TimerId, TimerKind, TimerStatus}; +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 @@ -182,6 +184,27 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu "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)), diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 54939b5..40cc6a8 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -380,6 +380,10 @@ pub fn router(registry: EventRegistry) -> Router { // 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)) @@ -589,6 +593,27 @@ 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 diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 9d49114..48dbaef 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -67,6 +67,7 @@ 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; 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 index 1c2e256..592c875 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -1382,6 +1382,7 @@ mod tests { channel_capability: ChannelCapability::Flexible, node_count, available_channels: available, + plugin: None, } } diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index d2645de..5c5d4fa 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -170,6 +170,42 @@ pub enum TimerStatus { 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 @@ -204,6 +240,13 @@ pub struct Timer { /// 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 @@ -359,6 +402,7 @@ impl TimerRegistry { 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); @@ -370,8 +414,10 @@ impl TimerRegistry { // 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). + // 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); } } @@ -429,6 +475,7 @@ impl TimerRegistry { 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()?; @@ -455,6 +502,8 @@ impl TimerRegistry { 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(); @@ -482,6 +531,17 @@ impl TimerRegistry { } } + /// 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. diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index d15abbf..09fc439 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -44,7 +44,7 @@ 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}; @@ -745,19 +745,35 @@ impl RhContainer { Self::start_with_plugin(port, update_interval, csvs, None) } - /// Like [`RhContainer::start`], but with an explicit `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. Used by - /// `cargo xtask rh-mock` (which can't set process env under `#![forbid(unsafe_code)]`). + /// 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 { - ensure_image(); - 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. @@ -787,20 +803,19 @@ impl RhContainer { node_index + 1 )); } - // Optionally boot against the GridFPV plugin (S0+): mount the host plugin dir - // into the container's user `plugins/gridfpv` (read-only). The plugin is - // additive — a placeholder loads cleanly and stock-RH behavior is unchanged. - if let Some(plugin_dir) = plugin_dir { - if plugin_dir.is_dir() { + // 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/gridfpv:ro", - plugin_dir.display() + "{}:{RH_SERVER_DIR}/plugins/{folder}:ro", + dir.display() )); } else { eprintln!( - "{PLUGIN_ENV}={} is not a directory; booting stock RH without the plugin", - plugin_dir.display() + "plugin dir {} for `{folder}` is not a directory; skipping that mount", + dir.display() ); } } diff --git a/frontend/apps/rd-console/src/lib/pluginPresence.ts b/frontend/apps/rd-console/src/lib/pluginPresence.ts new file mode 100644 index 0000000..fb37d38 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/pluginPresence.ts @@ -0,0 +1,78 @@ +// Interpret a timer's GridFPV-plugin presence for the console UI (RH plugin design D16, Slice 1). +// +// The backend probes a connected RotorHazard timer for the GridFPV plugin (the `gridfpv_hello` +// handshake) and reports a `PluginPresence` on the `Timer`. This turns that wire enum into the +// small view the timer row + guided-install prompt render. Kept here (not inline in the component) +// so the mapping is one place and unit-testable. Friendly-name rule: copy never names a URL. + +import type { Timer } from '@gridfpv/types'; +import { kindTag } from './timers.js'; + +/** The UI's coarse plugin state for a timer. */ +export type PluginViewKind = 'healthy' | 'missing' | 'incompatible'; + +export interface PluginView { + kind: PluginViewKind; + /** Badge tone for the row chip. */ + tone: 'success' | 'warn' | 'danger'; + /** Short chip label. */ + label: string; + /** Whether the guided one-step install should be offered (missing/incompatible). */ + needsInstall: boolean; + /** Modal heading. */ + title: string; + /** Longer explanation (version / mismatch reason). */ + detail?: string; +} + +/** + * Interpret a timer's plugin presence. Returns `null` for non-RotorHazard timers and for the + * not-yet-probed `Unknown` state — no chip, no noise before the timer has connected. + */ +export function pluginView(timer: Timer): PluginView | null { + // Only RotorHazard timers have a plugin; the Mock never does. + if (kindTag(timer.kind) !== 'Rotorhazard') return null; + + const p = timer.plugin; + // Not probed yet (pre-connect / Mock): show nothing until we actually know. + if (!p) return null; + + if (p === 'Missing') { + return { + kind: 'missing', + tone: 'warn', + label: 'plugin missing', + needsInstall: true, + title: 'GridFPV plugin needed', + detail: + 'This RotorHazard timer isn’t running the GridFPV plugin. Installing it unlocks live ' + + 'signal and clean start/stop. If RotorHazard is older than v4.3.0, update it first.' + }; + } + + if (typeof p === 'object' && 'Present' in p) { + return { + kind: 'healthy', + tone: 'success', + label: 'plugin ✓', + needsInstall: false, + title: 'GridFPV plugin active', + detail: `Plugin v${p.Present.plugin_version} · RHAPI ${p.Present.rhapi_version}` + }; + } + + // Incompatible. + return { + kind: 'incompatible', + tone: 'danger', + label: 'plugin update', + needsInstall: true, + title: 'GridFPV plugin update needed', + detail: p.Incompatible.reason + }; +} + +/** The URL the guided install downloads the plugin bundle from (served by the Director). */ +export function pluginBundleUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/$/, '')}/plugin/gridfpv.zip`; +} diff --git a/frontend/apps/rd-console/src/screens/PluginCallout.svelte b/frontend/apps/rd-console/src/screens/PluginCallout.svelte new file mode 100644 index 0000000..3013157 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/PluginCallout.svelte @@ -0,0 +1,93 @@ + + +{#if view} + {#if view.needsInstall} + + + +
    + +

    + {timer.name} needs the GridFPV plugin. Install it in one step: +

    + {#if view.detail}

    {view.detail}

    {/if} +
      +
    1. Download the GridFPV plugin folder.
    2. +
    3. + Drop the gridfpv folder into RotorHazard's plugins/ directory. +
    4. +
    5. Restart RotorHazard — the timer reconnects and the badge turns green.
    6. +
    +
    + {#snippet footer()} + + + {/snippet} +
    + {:else} + + {view.label} + + {/if} +{/if} + + diff --git a/frontend/apps/rd-console/src/screens/TimerManager.svelte b/frontend/apps/rd-console/src/screens/TimerManager.svelte index 545cd4e..8e59c55 100644 --- a/frontend/apps/rd-console/src/screens/TimerManager.svelte +++ b/frontend/apps/rd-console/src/screens/TimerManager.svelte @@ -56,6 +56,7 @@ isPlausibleMhz, type CapabilityTag } from '../lib/channels.js'; + import PluginCallout from './PluginCallout.svelte'; let { session, @@ -135,10 +136,12 @@ */ const displayTimers = $derived.by(() => { if (loadState.kind !== 'ready') return []; - const liveStatus = new Map(session.timers.map((t) => [t.id, t.status])); + // Overlay the poll-fresh, in-memory fields (connection `status` AND `plugin` presence) from the + // live-polled list; both are driven by the live connection and only refreshed there. + const live = new Map(session.timers.map((t) => [t.id, t])); return loadState.timers.map((t) => { - const status = liveStatus.get(t.id); - return status && status !== t.status ? { ...t, status } : t; + const l = live.get(t.id); + return l ? { ...t, status: l.status, plugin: l.plugin } : t; }); }); @@ -437,6 +440,7 @@
    +
    {#if !isBuiltInMock(timer)} diff --git a/frontend/apps/rd-console/tests/pluginPresence.test.ts b/frontend/apps/rd-console/tests/pluginPresence.test.ts new file mode 100644 index 0000000..c9b7a64 --- /dev/null +++ b/frontend/apps/rd-console/tests/pluginPresence.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for the GridFPV-plugin presence mapping (RH plugin design D16, Slice 1): the timer + * row chip + guided-install prompt read these views off a timer's `plugin` field. + */ +import { describe, expect, it } from 'vitest'; +import type { Timer } from '@gridfpv/types'; +import { pluginBundleUrl, pluginView } from '../src/lib/pluginPresence.js'; + +/** A RotorHazard timer with the given plugin presence (the field under test). */ +function rhTimer(plugin: Timer['plugin']): Timer { + return { + id: 't', + name: 'Track A', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Connected', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [], + plugin + }; +} + +describe('pluginView', () => { + it('returns null for a Mock timer (no plugin concept)', () => { + const mock: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [] + }; + expect(pluginView(mock)).toBeNull(); + }); + + it('returns null when not yet probed (undefined)', () => { + expect(pluginView(rhTimer(undefined))).toBeNull(); + }); + + it('flags Missing as a warn chip that needs install', () => { + const v = pluginView(rhTimer('Missing')); + expect(v).not.toBeNull(); + expect(v!.kind).toBe('missing'); + expect(v!.tone).toBe('warn'); + expect(v!.needsInstall).toBe(true); + }); + + it('shows Present as a healthy chip with version detail, no install', () => { + const v = pluginView( + rhTimer({ + Present: { plugin_version: '0.1.0', rhapi_version: '1.4', capabilities: ['hello'] } + }) + ); + expect(v!.kind).toBe('healthy'); + expect(v!.tone).toBe('success'); + expect(v!.needsInstall).toBe(false); + expect(v!.detail).toContain('0.1.0'); + expect(v!.detail).toContain('1.4'); + }); + + it('flags Incompatible as a danger chip that needs install, surfacing the reason', () => { + const v = pluginView( + rhTimer({ + Incompatible: { plugin_version: '9.9.9', protocol_version: 2, reason: 'protocol v2' } + }) + ); + expect(v!.kind).toBe('incompatible'); + expect(v!.tone).toBe('danger'); + expect(v!.needsInstall).toBe(true); + expect(v!.detail).toBe('protocol v2'); + }); +}); + +describe('pluginBundleUrl', () => { + it('joins the base URL with the bundle path, tolerating a trailing slash', () => { + expect(pluginBundleUrl('http://host:3000')).toBe('http://host:3000/plugin/gridfpv.zip'); + expect(pluginBundleUrl('http://host:3000/')).toBe('http://host:3000/plugin/gridfpv.zip'); + }); +}); diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index 5d94ae7..1d9e6ea 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -86,6 +86,7 @@ export type * from '@bindings/Pilot'; export type * from '@bindings/PilotId'; export type * from '@bindings/PilotProgress'; export type * from '@bindings/Placement'; +export type * from '@bindings/PluginPresence'; export type * from '@bindings/ProjectionBody'; export type * from '@bindings/ProjectionKind'; export type * from '@bindings/ProtestOutcome'; diff --git a/plugins/gridfpv/__init__.py b/plugins/gridfpv/__init__.py index 6e3f11b..1715c75 100644 --- a/plugins/gridfpv/__init__.py +++ b/plugins/gridfpv/__init__.py @@ -1,14 +1,16 @@ -"""GridFPV RotorHazard plugin — S0 placeholder. +"""GridFPV RotorHazard plugin. -This is the in-process RH integration described in ``docs/rotorhazard-plugin.html`` -(decision D16). At S0 it is an **empty, load-only skeleton**: RH's plugin loader -imports this module and calls :func:`initialize`, which currently does nothing. Its -sole job at S0 is to prove the loader path end-to-end inside the RH v4.4.0 dev -harness — RH logs the plugin as loaded with no ``load_issue``. +The in-process RH integration described in ``docs/rotorhazard-plugin.html`` (decision +D16). It runs inside the RH server with RHAPI access and talks to the GridFPV Director +over a ``gridfpv_*`` event namespace on RH's existing socket.io server. -The real handshake (``gridfpv_hello`` over ``rhapi.ui.socket_listen``), live dense -RSSI, clean start/stop, and the threshold recalculate all arrive in later slices -(S1–S4). Nothing here taps RHAPI yet. +**Slice 1 — handshake.** This registers the ``gridfpv_hello`` handshake: the Director +emits ``gridfpv_hello`` over the socket.io connection it already holds, and the plugin +replies with ``gridfpv_hello_ack`` carrying its protocol/plugin/RHAPI versions, its +declared capabilities, and the node count. That lets the Director detect a +plugin-equipped RH (and offer a guided install for one that's missing). The live dense +RSSI, clean start/stop, and threshold recalculate arrive in later slices (S2–S4); their +capability flags are added to ``CAPABILITIES`` as they land. Floor: RHAPI 1.3 / RotorHazard v4.3.0+ (declared in ``manifest.json``). """ @@ -17,12 +19,61 @@ logger = logging.getLogger(__name__) +# The gridfpv_* wire-protocol version. Bumped only on a breaking change to the +# handshake/message shapes; the Director declares the range it supports and negotiates +# against this in the hello/ack exchange. +PROTOCOL_VERSION = 1 + +# This plugin build's own version (independent of PROTOCOL_VERSION). Keep in step with +# manifest.json's "version". +PLUGIN_VERSION = "0.1.0" + +# Capabilities this build actually implements — the Director keys transport decisions off +# these (e.g. it only prefers the plugin's live-signal path once "live_signal" appears). +# S1 ships the handshake only; later slices append "live_signal", "clean_control", +# "recalc". +CAPABILITIES = ["hello"] + +# The socket.io event names of the gridfpv_* namespace (S1 subset). +EVT_HELLO = "gridfpv_hello" +EVT_HELLO_ACK = "gridfpv_hello_ack" + def initialize(rhapi): - """RH plugin entry point — called once by the loader with the ``rhapi`` object. + """RH plugin entry point — register the gridfpv_* handlers on RH's socket.io server. - S0 placeholder: log that we loaded, then return. No event hooks, no socket - handlers, no RHAPI calls yet (those land in S1+). ``rhapi`` is accepted and - intentionally unused so the signature matches what the loader invokes. + Called once by RH's loader with the ``rhapi`` object. We register a ``gridfpv_hello`` + listener that replies (to the asking client only) with ``gridfpv_hello_ack``. """ - logger.info("GridFPV plugin loaded (S0 placeholder — no handlers registered yet)") + + def on_hello(_data=None): + # `socket_listen` -> flask-socketio `on_event`; replying with `socket_send` + # emits in the asking client's request context, i.e. only back to the Director + # that sent the hello (not a broadcast). + ack = { + "protocol_version": PROTOCOL_VERSION, + "plugin_version": PLUGIN_VERSION, + "rhapi_version": "{0}.{1}".format( + getattr(rhapi, "API_VERSION_MAJOR", 1), + getattr(rhapi, "API_VERSION_MINOR", 0), + ), + "capabilities": CAPABILITIES, + "node_count": _node_count(rhapi), + } + logger.info("GridFPV hello -> ack %s", ack) + rhapi.ui.socket_send(EVT_HELLO_ACK, ack) + + rhapi.ui.socket_listen(EVT_HELLO, on_hello) + logger.info( + "GridFPV plugin loaded (v%s, protocol v%s) — gridfpv_hello handshake registered", + PLUGIN_VERSION, + PROTOCOL_VERSION, + ) + + +def _node_count(rhapi): + """Best-effort node/seat count from the live interface (0 if unavailable).""" + try: + return len(rhapi.interface.seats) + except Exception: # pragma: no cover - defensive; never fail the handshake on this + return 0 diff --git a/plugins/gridfpv/manifest.json b/plugins/gridfpv/manifest.json index 002262b..6a511fb 100644 --- a/plugins/gridfpv/manifest.json +++ b/plugins/gridfpv/manifest.json @@ -6,7 +6,7 @@ "info_uri": "https://github.com/GridFPV/gridfpv", "license": "MIT", "license_uri": "https://github.com/GridFPV/gridfpv/blob/main/LICENSE", - "version": "0.0.0", + "version": "0.1.0", "required_rhapi_version": "1.3", "update_uri": null, "text_domain": null diff --git a/plugins/gridfpv_mock/README.md b/plugins/gridfpv_mock/README.md new file mode 100644 index 0000000..6f07df1 --- /dev/null +++ b/plugins/gridfpv_mock/README.md @@ -0,0 +1,34 @@ +# GridFPV mock-control plugin — TEST ONLY + +A development/test companion to the real [`gridfpv`](../gridfpv/) plugin (RH plugin design +**D16**). It drives RotorHazard's **mock nodes from the network** over a `gridfpv_mock_*` +socket namespace, so a test or maintainer can shape the emulated signal live — without +rebuilding the container or remounting CSVs. It reuses the same `socket_listen` channel +plumbing the real plugin uses (S1). + +> ⚠️ **Never ship this to users.** It reaches into the live hardware interface +> (`rhapi._racecontext.interface`) — internals RHAPI deliberately doesn't expose — which is +> fine for a harness-only plugin but wrong in production. It is **not** included in the +> downloadable plugin bundle the Director serves; only [`gridfpv`](../gridfpv/) is. + +## Handlers + +Each replies `gridfpv_mock_ack` (`{action, ok, ...}`) to the asking client. + +| Event | Payload | Effect | +|-------|---------|--------| +| `gridfpv_mock_tune` | `{node, frequency}` | Set a mock node's frequency (activates it / starts CSV reads) | +| `gridfpv_mock_set_rssi` | `{node, rssi}` | Force a node's `current_rssi` (for inspection) | +| `gridfpv_mock_lap` | `{node}` | Inject a real lap via RH's `intf_simulate_lap` (genuine pipeline) — RH must be RACING | +| `gridfpv_mock_state` | — | Reply with per-node `{index, frequency, current_rssi}` | + +## Running it + +```sh +# Boot RH with the mock-control plugin mounted (optionally with the real plugin too): +cargo xtask rh-mock feed clean --mock-plugin # mock-control only +cargo xtask rh-mock feed clean --plugin --mock-plugin # both plugins +``` + +Then drive it from any socket.io client connected to RH (the Director's connection, a test, +or a quick Python script): emit `gridfpv_mock_*` and read `gridfpv_mock_ack`. diff --git a/plugins/gridfpv_mock/__init__.py b/plugins/gridfpv_mock/__init__.py new file mode 100644 index 0000000..eb10bfa --- /dev/null +++ b/plugins/gridfpv_mock/__init__.py @@ -0,0 +1,96 @@ +"""GridFPV **mock-node control** plugin — TEST ONLY. + +A development/test companion to the real ``gridfpv`` plugin (RH plugin design D16). It exposes a +``gridfpv_mock_*`` socket namespace that drives RotorHazard's **mock nodes from the network**, so a +test (or a maintainer) can shape the emulated signal live — set a node's frequency/RSSI, inject a +lap — without rebuilding the container or remounting CSVs. It reuses S1's ``socket_listen`` channel +plumbing. + +**Do not ship this to users.** It is strictly a harness aid: it reaches into the live hardware +interface (``rhapi._racecontext.interface``) — internals RHAPI deliberately does not expose — which +is fine for a test plugin (loaded only in the dev harness) but would be wrong in production. Keep it +out of the distributed ``gridfpv`` bundle. The real integration only ever uses sanctioned RHAPI. + +Handlers (each replies ``gridfpv_mock_ack`` to the asking client): +- ``gridfpv_mock_tune`` ``{node, frequency}`` — set a mock node's channel (so its ``mock_data`` CSV + is read / it goes active). +- ``gridfpv_mock_set_rssi`` ``{node, rssi}`` — force a node's ``current_rssi`` (for inspection). +- ``gridfpv_mock_lap`` ``{node}`` — inject a real lap via RH's own ``intf_simulate_lap`` (the same + proven path RH's built-in ``simulate_lap`` uses), so the pass flows through the genuine pipeline. +- ``gridfpv_mock_state`` — reply with per-node ``{index, frequency, current_rssi}``. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def initialize(rhapi): + """Register the gridfpv_mock_* control handlers on RH's socket.io server.""" + + def interface(): + # Semi-private reach into the live hardware interface — acceptable for a TEST plugin only. + return rhapi._racecontext.interface # noqa: SLF001 + + def ack(action, **fields): + rhapi.ui.socket_send("gridfpv_mock_ack", dict(action=action, ok=True, **fields)) + + def nack(action, error): + logger.warning("gridfpv_mock %s failed: %s", action, error) + rhapi.ui.socket_send("gridfpv_mock_ack", {"action": action, "ok": False, "error": str(error)}) + + def on_tune(data=None): + data = data or {} + try: + node = int(data["node"]) + freq = int(data["frequency"]) + # Set the node frequency directly. MockInterface.update only reads a node's mock_data + # CSV while `node.frequency` is truthy, so this is what activates a node. We bypass the + # interface's `set_frequency` (whose 4.4.0 signature wants band/channel labels) — this is + # a test control plugin, so the raw field write is exactly the intent. + interface().nodes[node].frequency = freq + ack("tune", node=node, frequency=freq) + except Exception as ex: # noqa: BLE001 - report, never crash RH + nack("tune", ex) + + def on_set_rssi(data=None): + data = data or {} + try: + node = int(data["node"]) + rssi = int(data["rssi"]) + interface().nodes[node].current_rssi = rssi + ack("set_rssi", node=node, rssi=rssi) + except Exception as ex: # noqa: BLE001 + nack("set_rssi", ex) + + def on_lap(data=None): + data = data or {} + try: + node = int(data["node"]) + ms_val = int(data.get("ms_val", 0)) + # The same call RH's built-in `simulate_lap` makes: records a real lap via the + # interface's pass_record_callback (genuine pipeline, not a faked event). + interface().intf_simulate_lap(node, ms_val) + ack("lap", node=node) + except Exception as ex: # noqa: BLE001 + nack("lap", ex) + + def on_state(_data=None): + try: + nodes = [ + { + "index": getattr(n, "index", i), + "frequency": getattr(n, "frequency", 0), + "current_rssi": getattr(n, "current_rssi", 0), + } + for i, n in enumerate(interface().nodes) + ] + ack("state", nodes=nodes) + except Exception as ex: # noqa: BLE001 + nack("state", ex) + + rhapi.ui.socket_listen("gridfpv_mock_tune", on_tune) + rhapi.ui.socket_listen("gridfpv_mock_set_rssi", on_set_rssi) + rhapi.ui.socket_listen("gridfpv_mock_lap", on_lap) + rhapi.ui.socket_listen("gridfpv_mock_state", on_state) + logger.info("GridFPV mock-control plugin loaded (TEST ONLY) — gridfpv_mock_* handlers registered") diff --git a/plugins/gridfpv_mock/manifest.json b/plugins/gridfpv_mock/manifest.json new file mode 100644 index 0000000..0a1fb7a --- /dev/null +++ b/plugins/gridfpv_mock/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "GridFPV Mock (test)", + "author": "GridFPV", + "author_uri": "https://github.com/GridFPV", + "description": "TEST-ONLY: network control of RotorHazard mock nodes (set RSSI/frequency, inject laps) over the gridfpv_mock_* socket namespace. Never ship this to users.", + "info_uri": "https://github.com/GridFPV/gridfpv", + "license": "MIT", + "license_uri": "https://github.com/GridFPV/gridfpv/blob/main/LICENSE", + "version": "0.1.0", + "required_rhapi_version": "1.3", + "update_uri": null, + "text_domain": null +} diff --git a/xtask/src/rh_mock.rs b/xtask/src/rh_mock.rs index ad34190..dfc1f23 100644 --- a/xtask/src/rh_mock.rs +++ b/xtask/src/rh_mock.rs @@ -60,9 +60,10 @@ fn usage() { eprintln!( "\nusage: cargo xtask rh-mock \n\ \n\ - feed [scenario] [--port P] [--tick T] [--plugin]\n\ + feed [scenario] [--port P] [--tick T] [--plugin] [--mock-plugin]\n\ \x20 spin up a real RotorHazard fed a mock signal\n\ - \x20 (--plugin mounts the GridFPV plugin too)\n\ + \x20 (--plugin = GridFPV plugin; --mock-plugin =\n\ + \x20 test-only gridfpv_mock_* node-control plugin)\n\ dump print the captured ?projection=signal values\n\ plugin-check [--port P] boot RH with the GridFPV plugin and confirm it loads\n\ list show the scenario menu\n\ @@ -74,10 +75,21 @@ fn usage() { /// The in-repo GridFPV plugin directory (`/plugins/gridfpv`) the harness mounts /// into RH's user `plugins/`. `CARGO_MANIFEST_DIR` is `/xtask`. fn plugin_dir() -> std::path::PathBuf { + workspace_root().join("plugins/gridfpv") +} + +/// The in-repo **test-only** mock-control plugin (`/plugins/gridfpv_mock`): network control +/// of the mock nodes (set RSSI/frequency, inject laps) over the `gridfpv_mock_*` socket namespace. +fn mock_plugin_dir() -> std::path::PathBuf { + workspace_root().join("plugins/gridfpv_mock") +} + +/// The workspace root (parent of this crate's `xtask` dir). +fn workspace_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("xtask manifest dir has a parent (workspace root)") - .join("plugins/gridfpv") + .to_path_buf() } // --------------------------------------------------------------------------------------------- @@ -182,6 +194,7 @@ fn feed(args: &[String]) -> bool { let mut port = DEFAULT_PORT; let mut tick = DEFAULT_TICK.to_string(); let mut with_plugin = false; + let mut with_mock = false; let mut it = args.iter(); while let Some(arg) = it.next() { @@ -201,6 +214,9 @@ fn feed(args: &[String]) -> bool { } }, "--plugin" => with_plugin = true, + // The test-only mock-control plugin (gridfpv_mock_* socket namespace). Implies the + // gridfpv plugin folder need not be present; mount it alongside whatever else is asked. + "--mock-plugin" => with_mock = true, other if other.starts_with("--") => { eprintln!("unknown flag: {other}"); return false; @@ -226,20 +242,34 @@ fn feed(args: &[String]) -> bool { let csvs = (scenario.build)(); let node_count = csvs.len(); - let plugin = with_plugin.then(plugin_dir); + // Build the plugin mount list from the flags (own the PathBufs; pass borrows to the testkit). + let main_dir = with_plugin.then(plugin_dir); + let mock_dir = with_mock.then(mock_plugin_dir); + let mut plugins: Vec<(&str, &std::path::Path)> = Vec::new(); + if let Some(d) = &main_dir { + plugins.push(("gridfpv", d.as_path())); + } + if let Some(d) = &mock_dir { + plugins.push(("gridfpv_mock", d.as_path())); + } + let plugin_note = match (with_plugin, with_mock) { + (true, true) => ", +GridFPV plugin +mock-control", + (true, false) => ", +GridFPV plugin", + (false, true) => ", +mock-control plugin", + (false, false) => "", + }; println!( "\n\x1b[1mStarting RotorHazard\x1b[0m with scenario \x1b[1m{}\x1b[0m on port {port} \ - ({node_count} emulated node(s), tick={tick}s{})...", - scenario.name, - if with_plugin { ", +GridFPV plugin" } else { "" } + ({node_count} emulated node(s), tick={tick}s{plugin_note})...", + scenario.name ); println!(" {}", scenario.blurb); - if let Some(dir) = &plugin { - println!(" mounting GridFPV plugin from {}", dir.display()); + for (folder, dir) in &plugins { + println!(" mounting plugin `{folder}` from {}", dir.display()); } // RAII: removed on drop (Ctrl-C below drops it). Blocks until the HTTP port is up. - let rh = RhContainer::start_with_plugin(port, &tick, &csvs, plugin); + let rh = RhContainer::start_with_plugins(port, &tick, &csvs, &plugins); let url = rh.url().to_string(); println!( From d96429cfb384a0bc3347e4454bfbf80288e8d4e3 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 02:46:59 +0000 Subject: [PATCH 241/362] feat(rotorhazard): live dense RSSI over the plugin (plugin S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice S2 of the GridFPV RotorHazard plugin (D16): the plugin pushes signal **live** so the Director builds the heat's dense trace during the race, retiring the post-race save-then-pull. Reuses the existing canonical signal events + projection unchanged. Plugin (plugins/gridfpv): - Declares the "live_signal" capability. On RACE_START a decimated (2 Hz) gevent loop broadcasts `gridfpv_signal` — per node: current_rssi, enter/exit levels, and the dense history_values/history_times window from rhapi.interface.seats — plus the race-start clock origin. Stops on RACE_STOP/FINISH with a final flush. Window capped + decimated (design risk #5). MockInterface populates node.history_values, so it's harness-testable. Adapter (crates/adapters): - transport.rs listens for `gridfpv_signal`; rotorhazard.rs adds Raw::GridSignal and translate_grid_signal, folding it into the existing SignalThresholds + SignalHistory (reusing emit_dense_history, race-relative via race_start). The plugin re-sends the full window each tick; the adapter emits a dense SignalHistory only when a node's history has GROWN (last_history_len) to keep the log to ~one dense fact per crossing. - Seeing any `gridfpv_signal` sets live_signal_active, which SUPPRESSES the post-race save-then-pull on the DONE edge. A stock RH (no plugin) still falls back to the pull. Projection (crates/projection): - dense_trace_grid now derives period_micros from the first POSITIVE inter-sample delta. A live dense history legitimately repeats a timestamp (a peak at equal first/last time -> [t, t, …]), which previously yielded a degenerate period of 0. Tests: - Adapter unit tests: RawGridSignal -> SignalHistory/SignalThresholds; grown-only dense emission; live signal suppresses the marshal pull on DONE. Projection unit test for the repeated-timestamp period. The two live tests that asserted the old "dense > coarse" save-then-pull premise are now path-aware (plugin mounted = live dense, dense == coarse). Gates: cargo xtask ci green; full cargo xtask live green (17/17 targets, plugin mounted). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapters/src/rotorhazard.rs | 258 +++++++++++++++++-- crates/adapters/src/rotorhazard/transport.rs | 21 +- crates/adapters/tests/rh_signal.rs | 58 +++-- crates/app/tests/rh_connect_live.rs | 22 +- crates/projection/src/lib.rs | 41 ++- plugins/gridfpv/README.md | 32 +-- plugins/gridfpv/__init__.py | 129 ++++++++-- 7 files changed, 475 insertions(+), 86 deletions(-) diff --git a/crates/adapters/src/rotorhazard.rs b/crates/adapters/src/rotorhazard.rs index 7386566..4ce2c10 100644 --- a/crates/adapters/src/rotorhazard.rs +++ b/crates/adapters/src/rotorhazard.rs @@ -166,6 +166,13 @@ pub enum Raw { /// 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 RotorHazard `race_status` message (see [`Raw::RaceStatus`]). @@ -495,6 +502,44 @@ where }) } +/// 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 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, +} + /// The competitor handle for a RotorHazard node seat: `"node-{index}"`. Stable across /// pilot reassignment (the binding to a GridFPV pilot is a registration action, not /// an adapter event — see `gridfpv_events::CompetitorRef`). @@ -555,6 +600,17 @@ pub struct RotorHazardAdapter { /// 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 length of the dense history last emitted as a [`SignalHistory`], reset each race. The + /// plugin re-broadcasts the full window every tick; emitting only when a node's history has + /// **grown** keeps the log to roughly one dense fact per crossing instead of one per broadcast. + last_history_len: std::collections::HashMap, /// 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: @@ -619,6 +675,8 @@ impl RotorHazardAdapter { sample_index: std::collections::HashMap::new(), last_thresholds: std::collections::HashMap::new(), pending_marshal_request: false, + live_signal_active: false, + last_history_len: std::collections::HashMap::new(), pending_pilotrace_requests: Vec::new(), pilotrace_start_time: std::collections::HashMap::new(), pending_heat_ids: Vec::new(), @@ -797,6 +855,10 @@ impl RotorHazardAdapter { 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: forget the previous heat's emitted lengths so the new + // heat's growing history emits fresh `SignalHistory` facts. (`live_signal_active` + // persists — once the plugin is streaming, it streams every heat.) + self.last_history_len.clear(); self.pending_pilotrace_requests.clear(); self.pilotrace_start_time.clear(); out.push(Event::SessionStarted { @@ -813,7 +875,9 @@ impl RotorHazardAdapter { // 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. - if self.signal_capture { + // ...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 { @@ -1041,22 +1105,70 @@ impl RotorHazardAdapter { .len() .min(levels.exit_at_levels.len()); for node_index in 0..n { - let enter = levels.enter_at_levels[node_index] - .round() - .clamp(0.0, u16::MAX as f32) as u16; - let exit = levels.exit_at_levels[node_index] - .round() - .clamp(0.0, u16::MAX as f32) as u16; - if self.last_thresholds.get(&node_index) == Some(&(enter, exit)) { - continue; + 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: emit only when this node's window has grown since the last emit, and + // only with a known race origin to anchor the race-relative times. + let len = node.history_values.len().min(node.history_times.len()); + let grown = len > self.last_history_len.get(&node_index).copied().unwrap_or(0); + if let Some(start) = sig.race_start { + if len > 0 && grown { + self.last_history_len.insert(node_index, len); + self.emit_dense_history( + node_index, + start, + &node.history_times, + &node.history_values, + out, + ); + } } - self.last_thresholds.insert(node_index, (enter, exit)); - out.push(Event::SignalThresholds(SignalThresholds { - adapter: self.id.clone(), - competitor: seat_ref(node_index), - enter, - exit, - })); } } } @@ -1092,6 +1204,7 @@ impl Adapter for RotorHazardAdapter { 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), } out } @@ -1876,6 +1989,119 @@ mod tests { ); } + /// Build a `gridfpv_signal` broadcast (D16, S2) with one node's dense window + thresholds. + fn grid_signal( + race_start: f64, + node: usize, + enter: f64, + exit: f64, + times: &[f64], + values: &[f64], + ) -> Raw { + Raw::GridSignal(RawGridSignal { + race_start: Some(race_start), + nodes: vec![RawGridSignalNode { + index: node, + 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, + 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_dense_emitted_only_on_growth() { + let mut a = RotorHazardAdapter::new(); + // The plugin re-sends the full window each tick; we emit a dense history only when it grows. + let first = a.translate(grid_signal(0.0, 0, 90.0, 80.0, &[0.1, 0.2], &[70.0, 150.0])); + assert_eq!( + histories(&first).len(), + 1, + "first window emits a dense history" + ); + assert_eq!(thresholds(&first).len(), 1, "first window emits thresholds"); + + let again = a.translate(grid_signal(0.0, 0, 90.0, 80.0, &[0.1, 0.2], &[70.0, 150.0])); + assert!( + histories(&again).is_empty(), + "an unchanged window emits no new dense history" + ); + assert!( + thresholds(&again).is_empty(), + "unchanged thresholds are not re-emitted" + ); + + let grown = a.translate(grid_signal( + 0.0, + 0, + 90.0, + 80.0, + &[0.1, 0.2, 0.3], + &[70.0, 150.0, 71.0], + )); + assert_eq!( + histories(&grown).len(), + 1, + "a grown window emits an updated dense history" + ); + assert_eq!(histories(&grown)[0].rssi, vec![70, 150, 71]); + } + + #[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, 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 marshal_data_emits_dense_history_race_relative_micros() { let mut adapter = RotorHazardAdapter::with_id(AdapterId("rh".into())); diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index bb756cd..f37ae63 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -38,8 +38,9 @@ use rust_socketio::{ClientBuilder, Payload, RawClient}; use serde_json::json; use super::{ - Raw, RawCurrentLaps, RawEnterExitLevels, RawHeatData, RawMarshalData, RawNodeData, - RawPassRecord, RawPilotData, RawRaceDetails, RawRaceList, RawRaceStatus, RotorHazardAdapter, + Raw, RawCurrentLaps, RawEnterExitLevels, RawGridSignal, RawHeatData, RawMarshalData, + RawNodeData, RawPassRecord, RawPilotData, RawRaceDetails, RawRaceList, RawRaceStatus, + RotorHazardAdapter, }; use crate::Adapter; use gridfpv_events::Event; @@ -85,6 +86,10 @@ pub fn raw_from_socket(event: &str, payload: &Payload) -> Option { "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), _ => None, } } @@ -393,6 +398,18 @@ impl RotorHazardConnection { 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 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", { diff --git a/crates/adapters/tests/rh_signal.rs b/crates/adapters/tests/rh_signal.rs index 4ebb750..6b1450d 100644 --- a/crates/adapters/tests/rh_signal.rs +++ b/crates/adapters/tests/rh_signal.rs @@ -447,38 +447,49 @@ fn dense_marshal_history_supersedes_coarse_stream() { "race never reached RACING" ); - // Let a run of coarse streamed samples accumulate. + // 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 coarse trace"); + assert!(captured_enough, "node-0 never accumulated a trace"); - // The coarse streamed sample count, before any dense history is pulled. + // 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); - // Finish the heat: the DONE transition auto-triggers the heat-end marshal pull (the transport - // emits `current_race_marshal`, then `save_laps` + a `race_list` request whose ids drive - // `get_pilotrace` — whichever the server implements answers). - conn.stop_race().ok(); - - // Wait for the dense `SignalHistory` to arrive and supersede the coarse stream. The transport - // drives the pull automatically off the DONE edge; keep draining while it completes. - let got_dense = wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { - evs.iter().any(|e| matches!(e, Event::SignalHistory(_))) - }); + // 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 SignalHistory pulled after heat end (coarse samples were {coarse_samples})" + "no dense SignalHistory produced (coarse samples were {coarse_samples}, plugin_live={plugin_live})" ); let dense_samples = signal_trace(&events) @@ -486,11 +497,20 @@ fn dense_marshal_history_supersedes_coarse_stream() { .map(|t| t.samples.len()) .expect("node-0 dense trace"); - assert!( - dense_samples > coarse_samples, - "dense history must carry MORE samples than the coarse stream: dense={dense_samples} \ - coarse={coarse_samples}" - ); + 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 diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 849b64a..87cbaf5 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -368,11 +368,23 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu .competitor(&pilot_key) .map(|t| t.samples.len()) .expect("dense trace for the lineup pilot"); - assert!( - dense_samples > coarse_samples, - "the dense history must supersede the coarse stream with MORE samples: dense={dense_samples} \ - coarse={coarse_samples}" - ); + // 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)" diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 668e757..9012dee 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -923,16 +923,21 @@ where /// 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 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 single-sample history yields `period_micros = 0`. Times that are -/// non-monotonic or out of range are clamped to a non-negative `u32` delta so the grid stays valid. +/// `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 = match history.times.get(..2) { - Some([a, b]) => (b - a).clamp(0, u32::MAX as i64) as u32, - _ => 0, - }; + 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()) } @@ -2145,4 +2150,24 @@ mod marshaling_tests { 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/plugins/gridfpv/README.md b/plugins/gridfpv/README.md index 6505a9f..e2ad8af 100644 --- a/plugins/gridfpv/README.md +++ b/plugins/gridfpv/README.md @@ -10,23 +10,25 @@ A RotorHazard plugin is a directory dropped into RH's user-`plugins/` dir, with - **Floor:** RHAPI **1.3** / RotorHazard **v4.3.0+** (declared in `manifest.json`). - **Channel:** `gridfpv_*` events on RH's existing socket.io server (S1+). -## Status — S0 (placeholder) +## Status — S2 (handshake + live dense RSSI) -`__init__.py` is an empty, load-only skeleton: `initialize(rhapi)` logs and returns. -S0's job is the **dev harness**, not plugin logic — see -[`docker/rotorhazard/README.md`](../../docker/rotorhazard/README.md). The harness -mounts this folder into the RH v4.4.0 container's `plugins/gridfpv/` and boots it; -RH must log the plugin as loaded with no `load_issue`. Confirm with: +`initialize(rhapi)` registers two things: -```sh -cargo xtask rh-mock plugin-check -``` +- **Handshake** — `gridfpv_hello` → `gridfpv_hello_ack` (versions, `CAPABILITIES`, node + count) so the Director can detect the plugin and offer a guided install for a missing one. +- **Live dense RSSI** — while a race runs, a decimated loop broadcasts `gridfpv_signal` + (per-node `current_rssi`, enter/exit levels, and the dense `history_values`/`history_times` + window) + the race-start clock. The Director folds it into the heat's signal trace **live**, + retiring the post-race save-then-pull. + +Confirm the plugin loads with `cargo xtask rh-mock plugin-check`; drive a live heat with +`cargo xtask rh-mock feed clean --plugin` and watch `gridfpv_signal` flow. ## Roadmap -| Slice | Adds | -|-------|------| -| S1 | `gridfpv_hello` handshake via `socket_listen`; capabilities/version | -| S2 | live dense RSSI broadcast (`gridfpv_signal`) — retires save-then-pull | -| S3 | clean start/stop (`race.stage()`/`stop()`) + per-node passes | -| S4 | threshold recalculate (#3) over the stored dense trace + `frequencyset_alter` | +| Slice | Adds | Status | +|-------|------|--------| +| S1 | `gridfpv_hello` handshake via `socket_listen`; capabilities/version | ✅ shipped | +| S2 | live dense RSSI broadcast (`gridfpv_signal`) — retires save-then-pull | ✅ shipped | +| S3 | clean start/stop (`race.stage()`/`stop()`) + per-node passes | next | +| S4 | threshold recalculate (#3) over the stored dense trace + `frequencyset_alter` | | diff --git a/plugins/gridfpv/__init__.py b/plugins/gridfpv/__init__.py index 1715c75..603b549 100644 --- a/plugins/gridfpv/__init__.py +++ b/plugins/gridfpv/__init__.py @@ -4,19 +4,31 @@ D16). It runs inside the RH server with RHAPI access and talks to the GridFPV Director over a ``gridfpv_*`` event namespace on RH's existing socket.io server. -**Slice 1 — handshake.** This registers the ``gridfpv_hello`` handshake: the Director -emits ``gridfpv_hello`` over the socket.io connection it already holds, and the plugin -replies with ``gridfpv_hello_ack`` carrying its protocol/plugin/RHAPI versions, its -declared capabilities, and the node count. That lets the Director detect a -plugin-equipped RH (and offer a guided install for one that's missing). The live dense -RSSI, clean start/stop, and threshold recalculate arrive in later slices (S2–S4); their -capability flags are added to ``CAPABILITIES`` as they land. +**Slice 1 — handshake.** ``gridfpv_hello`` → ``gridfpv_hello_ack`` so the Director can +detect a plugin-equipped RH (versions, capabilities, node count) and offer a guided +install for one that's missing. + +**Slice 2 — live dense RSSI.** While a race runs, the plugin broadcasts ``gridfpv_signal`` +(decimated): per-node ``current_rssi`` plus the dense ``history_values``/``history_times`` +window read live from ``rhapi.interface.seats``, plus the enter/exit detection levels and +the race-start clock origin. The Director folds this into the heat's signal trace **live**, +retiring the post-race save-then-pull. Declared via the ``"live_signal"`` capability. + +Clean start/stop and threshold recalculate arrive in S3–S4. Floor: RHAPI 1.3 / RotorHazard v4.3.0+ (declared in ``manifest.json``). """ import logging +import gevent # RH's runtime; used for the decimated broadcast greenlet. + +try: + # RH's event constants — the lifecycle hooks we attach the signal loop to. + from eventmanager import Evt +except Exception: # pragma: no cover - only importable inside the RH server + Evt = None + logger = logging.getLogger(__name__) # The gridfpv_* wire-protocol version. Bumped only on a breaking change to the @@ -28,28 +40,34 @@ # manifest.json's "version". PLUGIN_VERSION = "0.1.0" -# Capabilities this build actually implements — the Director keys transport decisions off -# these (e.g. it only prefers the plugin's live-signal path once "live_signal" appears). -# S1 ships the handshake only; later slices append "live_signal", "clean_control", -# "recalc". -CAPABILITIES = ["hello"] +# Capabilities this build implements — the Director keys transport decisions off these +# (e.g. it prefers the plugin's live-signal path, and skips the post-race pull, once it +# sees "live_signal"). Later slices append "clean_control", "recalc". +CAPABILITIES = ["hello", "live_signal"] -# The socket.io event names of the gridfpv_* namespace (S1 subset). +# Socket.io event names of the gridfpv_* namespace. EVT_HELLO = "gridfpv_hello" EVT_HELLO_ACK = "gridfpv_hello_ack" +EVT_SIGNAL = "gridfpv_signal" + +# Live-signal broadcast cadence (seconds) — decimated so the stream stays cheap on a Pi +# (design risk #5). 0.5 s = 2 Hz; the Director gets a fresh dense trace twice a second. +SIGNAL_INTERVAL = 0.5 +# Cap on the per-node dense window sent each broadcast (most-recent samples). RH already +# prunes node history to ~60 s; this bounds a pathological burst. A normal heat fits well +# under this. (A future optimization streams only the incremental slice; see the doc.) +SIGNAL_WINDOW = 2000 def initialize(rhapi): - """RH plugin entry point — register the gridfpv_* handlers on RH's socket.io server. + """RH plugin entry point — register the gridfpv_* handlers + the live-signal loop.""" - Called once by RH's loader with the ``rhapi`` object. We register a ``gridfpv_hello`` - listener that replies (to the asking client only) with ``gridfpv_hello_ack``. - """ + # The running broadcast greenlet (None when idle). A dict so the inner handlers can + # rebind it without `nonlocal` gymnastics. + state = {"greenlet": None} + # ---- S1: handshake ----------------------------------------------------------------- def on_hello(_data=None): - # `socket_listen` -> flask-socketio `on_event`; replying with `socket_send` - # emits in the asking client's request context, i.e. only back to the Director - # that sent the hello (not a broadcast). ack = { "protocol_version": PROTOCOL_VERSION, "plugin_version": PLUGIN_VERSION, @@ -64,8 +82,77 @@ def on_hello(_data=None): rhapi.ui.socket_send(EVT_HELLO_ACK, ack) rhapi.ui.socket_listen(EVT_HELLO, on_hello) + + # ---- S2: live dense RSSI ----------------------------------------------------------- + def broadcast_signal_once(): + """Read the live per-node signal off the interface and broadcast one snapshot.""" + seats = getattr(rhapi.interface, "seats", []) or [] + nodes = [] + for n in seats: + hv = list(getattr(n, "history_values", None) or []) + ht = list(getattr(n, "history_times", None) or []) + if len(hv) > SIGNAL_WINDOW: + hv = hv[-SIGNAL_WINDOW:] + ht = ht[-SIGNAL_WINDOW:] + nodes.append( + { + "index": getattr(n, "index", 0), + "frequency": getattr(n, "frequency", 0), + "current_rssi": getattr(n, "current_rssi", 0), + "enter_at": getattr(n, "enter_at_level", 0), + "exit_at": getattr(n, "exit_at_level", 0), + "history_values": hv, + "history_times": ht, + } + ) + payload = { + # The race-start origin (RH's monotonic seconds) so the Director can make the + # dense `history_times` race-relative — the same anchor the marshal-data path uses. + "race_start": getattr(rhapi.race, "start_time_internal", None), + "nodes": nodes, + } + rhapi.ui.socket_broadcast(EVT_SIGNAL, payload) + + def signal_loop(): + try: + while True: + broadcast_signal_once() + gevent.sleep(SIGNAL_INTERVAL) + except gevent.GreenletExit: # killed on race stop — normal + pass + except Exception: # noqa: BLE001 - never let the loop crash take down RH + logger.exception("GridFPV signal loop error") + + def start_signal(_args=None): + # Cancel any stale loop, then start a fresh one for this race. + g = state.get("greenlet") + if g is not None: + g.kill(block=False) + state["greenlet"] = gevent.spawn(signal_loop) + logger.info("GridFPV live signal: streaming %s every %ss", EVT_SIGNAL, SIGNAL_INTERVAL) + + def stop_signal(_args=None): + g = state.get("greenlet") + if g is None: + return + g.kill(block=False) + state["greenlet"] = None + # Final full snapshot so the Director has the complete trace for the heat even if + # it missed the last tick — the live equivalent of the old post-race pull. + try: + broadcast_signal_once() + except Exception: # noqa: BLE001 + logger.exception("GridFPV final signal flush error") + + if Evt is not None: + rhapi.events.on(Evt.RACE_START, start_signal, name="gridfpv_signal_start") + rhapi.events.on(Evt.RACE_STOP, stop_signal, name="gridfpv_signal_stop") + rhapi.events.on(Evt.RACE_FINISH, stop_signal, name="gridfpv_signal_finish") + else: # pragma: no cover + logger.warning("GridFPV: eventmanager.Evt unavailable; live signal disabled") + logger.info( - "GridFPV plugin loaded (v%s, protocol v%s) — gridfpv_hello handshake registered", + "GridFPV plugin loaded (v%s, protocol v%s) — handshake + live signal registered", PLUGIN_VERSION, PROTOCOL_VERSION, ) From 10c4b65a22c6eebdcffa428aa503214afebe260b Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 03:04:03 +0000 Subject: [PATCH 242/362] perf(rotorhazard): incremental dense signal streaming (plugin S2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S2 plugin re-broadcast the full per-node history window every tick (O(n²) wire over a heat). Now it keeps a per-race append-only accumulator and sends only the NEW samples since the last tick, with a `base` index so the Director appends (or replaces on a full snapshot). The final flush sends the full trace (base 0) so the end state is complete even across a missed/reconnected tick. Plugin: per-race {index: {t, v, sent}} accumulator; reconcile() appends RH history newer than what we hold (by timestamp — robust to RH's 60s front-pruning); broadcast sends acc[base:]. Verified in-container: per-tick slices are ~constant-size deltas (base advancing) instead of the growing full window; final flush carries the full trace. Adapter: RawGridSignalNode gains `base`; translate_grid_signal accumulates per node (REPLACE on base==0, APPEND on base==len, skip out-of-sync) and emits the accumulated SignalHistory only when it changed (a redundant final flush is a no-op). Replaces the prior full-window growth check. Gates: cargo xtask ci green; rh_signal (3) + rh_connect_live (2) green with the plugin. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapters/src/rotorhazard.rs | 143 ++++++++++++++++++++--------- plugins/gridfpv/__init__.py | 93 +++++++++++++------ 2 files changed, 167 insertions(+), 69 deletions(-) diff --git a/crates/adapters/src/rotorhazard.rs b/crates/adapters/src/rotorhazard.rs index 4ce2c10..b786a60 100644 --- a/crates/adapters/src/rotorhazard.rs +++ b/crates/adapters/src/rotorhazard.rs @@ -522,6 +522,12 @@ pub struct RawGridSignal { 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, @@ -607,10 +613,11 @@ pub struct RotorHazardAdapter { /// 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 length of the dense history last emitted as a [`SignalHistory`], reset each race. The - /// plugin re-broadcasts the full window every tick; emitting only when a node's history has - /// **grown** keeps the log to roughly one dense fact per crossing instead of one per broadcast. - last_history_len: std::collections::HashMap, + /// 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: @@ -676,7 +683,7 @@ impl RotorHazardAdapter { last_thresholds: std::collections::HashMap::new(), pending_marshal_request: false, live_signal_active: false, - last_history_len: std::collections::HashMap::new(), + dense_accum: std::collections::HashMap::new(), pending_pilotrace_requests: Vec::new(), pilotrace_start_time: std::collections::HashMap::new(), pending_heat_ids: Vec::new(), @@ -855,10 +862,10 @@ impl RotorHazardAdapter { 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: forget the previous heat's emitted lengths so the new - // heat's growing history emits fresh `SignalHistory` facts. (`live_signal_active` - // persists — once the plugin is streaming, it streams every heat.) - self.last_history_len.clear(); + // 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 { @@ -1153,21 +1160,43 @@ impl RotorHazardAdapter { if let (Some(enter), Some(exit)) = (node.enter_at, node.exit_at) { self.emit_threshold(node_index, enter, exit, out); } - // Dense history: emit only when this node's window has grown since the last emit, and - // only with a known race origin to anchor the race-relative times. - let len = node.history_values.len().min(node.history_times.len()); - let grown = len > self.last_history_len.get(&node_index).copied().unwrap_or(0); - if let Some(start) = sig.race_start { - if len > 0 && grown { - self.last_history_len.insert(node_index, len); - self.emit_dense_history( - node_index, - start, - &node.history_times, - &node.history_values, - 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(), + })); } } } @@ -1989,10 +2018,12 @@ mod tests { ); } - /// Build a `gridfpv_signal` broadcast (D16, S2) with one node's dense window + thresholds. + /// 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], @@ -2002,6 +2033,7 @@ mod tests { race_start: Some(race_start), nodes: vec![RawGridSignalNode { index: node, + base, current_rssi: values.last().copied(), enter_at: Some(enter), exit_at: Some(exit), @@ -2028,6 +2060,7 @@ mod tests { let events = a.translate(grid_signal( 10.0, 2, + 0, 90.0, 80.0, &[10.1, 10.2, 10.3], @@ -2045,41 +2078,69 @@ mod tests { } #[test] - fn grid_signal_dense_emitted_only_on_growth() { + fn grid_signal_accumulates_incremental_slices() { let mut a = RotorHazardAdapter::new(); - // The plugin re-sends the full window each tick; we emit a dense history only when it grows. - let first = a.translate(grid_signal(0.0, 0, 90.0, 80.0, &[0.1, 0.2], &[70.0, 150.0])); + // 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 window emits a dense history" + "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" ); - assert_eq!(thresholds(&first).len(), 1, "first window emits thresholds"); - let again = a.translate(grid_signal(0.0, 0, 90.0, 80.0, &[0.1, 0.2], &[70.0, 150.0])); - assert!( - histories(&again).is_empty(), - "an unchanged window emits no new dense history" + // 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(&again).is_empty(), + thresholds(&appended).is_empty(), "unchanged thresholds are not re-emitted" ); - let grown = a.translate(grid_signal( + // 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_eq!( - histories(&grown).len(), - 1, - "a grown window emits an updated dense history" + 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" ); - assert_eq!(histories(&grown)[0].rssi, vec![70, 150, 71]); } #[test] @@ -2090,7 +2151,7 @@ mod tests { race_heat_id: Some(1), })); // The plugin pushes live signal during the race... - a.translate(grid_signal(0.0, 0, 90.0, 80.0, &[0.1], &[150.0])); + 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), diff --git a/plugins/gridfpv/__init__.py b/plugins/gridfpv/__init__.py index 603b549..93f3fc0 100644 --- a/plugins/gridfpv/__init__.py +++ b/plugins/gridfpv/__init__.py @@ -19,6 +19,7 @@ Floor: RHAPI 1.3 / RotorHazard v4.3.0+ (declared in ``manifest.json``). """ +import bisect import logging import gevent # RH's runtime; used for the decimated broadcast greenlet. @@ -51,12 +52,13 @@ EVT_SIGNAL = "gridfpv_signal" # Live-signal broadcast cadence (seconds) — decimated so the stream stays cheap on a Pi -# (design risk #5). 0.5 s = 2 Hz; the Director gets a fresh dense trace twice a second. +# (design risk #5). 0.5 s = 2 Hz. Each tick sends only the NEW dense samples since the last +# (incremental), so the per-tick payload is tiny regardless of heat length. SIGNAL_INTERVAL = 0.5 -# Cap on the per-node dense window sent each broadcast (most-recent samples). RH already -# prunes node history to ~60 s; this bounds a pathological burst. A normal heat fits well -# under this. (A future optimization streams only the incremental slice; see the doc.) -SIGNAL_WINDOW = 2000 +# Safety cap on the per-race accumulator length (samples) so a pathological run can't grow +# memory unbounded — a real heat's peak/nadir history is far smaller. When exceeded, the +# oldest samples are dropped (the Director keeps what it already folded). +SIGNAL_WINDOW = 20000 def initialize(rhapi): @@ -64,7 +66,9 @@ def initialize(rhapi): # The running broadcast greenlet (None when idle). A dict so the inner handlers can # rebind it without `nonlocal` gymnastics. - state = {"greenlet": None} + # `greenlet`: the running broadcast loop. `acc`: per-race, per-node append-only dense buffer + # {index: {"t": [secs], "v": [rssi], "sent": int}} — the source of the incremental slices. + state = {"greenlet": None, "acc": {}} # ---- S1: handshake ----------------------------------------------------------------- def on_hello(_data=None): @@ -83,32 +87,64 @@ def on_hello(_data=None): rhapi.ui.socket_listen(EVT_HELLO, on_hello) - # ---- S2: live dense RSSI ----------------------------------------------------------- - def broadcast_signal_once(): - """Read the live per-node signal off the interface and broadcast one snapshot.""" + # ---- S2: live dense RSSI (incremental) --------------------------------------------- + def reconcile(seat): + """Merge a seat's current RH history into our per-race append-only accumulator; return + ``(index, acc)``. + + RH prunes its own node history to ~60 s and reports peak/nadir entries (which can repeat a + timestamp), so we keep the full per-race trace ourselves and append only samples newer than + the last we hold — found by timestamp (monotonic), which is robust to RH front-pruning. + """ + idx = getattr(seat, "index", 0) + acc = state["acc"].setdefault(idx, {"t": [], "v": [], "sent": 0}) + ht = list(getattr(seat, "history_times", None) or []) + hv = list(getattr(seat, "history_values", None) or []) + n = min(len(ht), len(hv)) + if n: + # First index in RH's (possibly front-pruned) buffer past what we already hold. + start = bisect.bisect_right(ht, acc["t"][-1], hi=n) if acc["t"] else 0 + for i in range(start, n): + acc["t"].append(ht[i]) + acc["v"].append(hv[i]) + if len(acc["t"]) > SIGNAL_WINDOW: # safety: drop oldest if pathologically long + drop = len(acc["t"]) - SIGNAL_WINDOW + del acc["t"][:drop] + del acc["v"][:drop] + acc["sent"] = max(0, acc["sent"] - drop) + return idx, acc + + def broadcast_signal_once(final=False): + """Broadcast each seat's NEW dense samples since the last tick (incremental). + + Each node carries ``base`` — the accumulator index this slice starts at — so the Director + can append at ``base`` (or REPLACE when ``base == 0``). ``final`` sends the full accumulated + trace (base 0) so the Director's end state is complete even if it missed ticks. + """ seats = getattr(rhapi.interface, "seats", []) or [] nodes = [] - for n in seats: - hv = list(getattr(n, "history_values", None) or []) - ht = list(getattr(n, "history_times", None) or []) - if len(hv) > SIGNAL_WINDOW: - hv = hv[-SIGNAL_WINDOW:] - ht = ht[-SIGNAL_WINDOW:] + for seat in seats: + idx, acc = reconcile(seat) + base = 0 if final else acc["sent"] nodes.append( { - "index": getattr(n, "index", 0), - "frequency": getattr(n, "frequency", 0), - "current_rssi": getattr(n, "current_rssi", 0), - "enter_at": getattr(n, "enter_at_level", 0), - "exit_at": getattr(n, "exit_at_level", 0), - "history_values": hv, - "history_times": ht, + "index": idx, + "frequency": getattr(seat, "frequency", 0), + "current_rssi": getattr(seat, "current_rssi", 0), + "enter_at": getattr(seat, "enter_at_level", 0), + "exit_at": getattr(seat, "exit_at_level", 0), + "base": base, + "history_values": acc["v"][base:], + "history_times": acc["t"][base:], } ) + if not final: + acc["sent"] = len(acc["t"]) payload = { - # The race-start origin (RH's monotonic seconds) so the Director can make the - # dense `history_times` race-relative — the same anchor the marshal-data path uses. + # The race-start origin (RH's monotonic seconds) so the Director can make the dense + # `history_times` race-relative — the same anchor the marshal-data path uses. "race_start": getattr(rhapi.race, "start_time_internal", None), + "final": final, "nodes": nodes, } rhapi.ui.socket_broadcast(EVT_SIGNAL, payload) @@ -124,10 +160,11 @@ def signal_loop(): logger.exception("GridFPV signal loop error") def start_signal(_args=None): - # Cancel any stale loop, then start a fresh one for this race. + # Cancel any stale loop, reset the per-race accumulator, then start a fresh loop. g = state.get("greenlet") if g is not None: g.kill(block=False) + state["acc"] = {} state["greenlet"] = gevent.spawn(signal_loop) logger.info("GridFPV live signal: streaming %s every %ss", EVT_SIGNAL, SIGNAL_INTERVAL) @@ -137,10 +174,10 @@ def stop_signal(_args=None): return g.kill(block=False) state["greenlet"] = None - # Final full snapshot so the Director has the complete trace for the heat even if - # it missed the last tick — the live equivalent of the old post-race pull. + # Final full snapshot (base 0 = replace) so the Director has the complete trace for the heat + # even if it missed ticks (e.g. a mid-heat reconnect) — the live equivalent of the old pull. try: - broadcast_signal_once() + broadcast_signal_once(final=True) except Exception: # noqa: BLE001 logger.exception("GridFPV final signal flush error") From 694a30ef2662db6d6eb83b57d1df79ff758783ea Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 03:14:48 +0000 Subject: [PATCH 243/362] test(rotorhazard): relax rh_signal trace-from assertion for the live path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live dense trace anchors at the first sample's actual race-relative time (a small positive offset), not the marshal-pull path's zeroed origin. Assert the trace is anchored within the opening second rather than exactly 0 — removes a timing-dependent flake. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapters/tests/rh_signal.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/adapters/tests/rh_signal.rs b/crates/adapters/tests/rh_signal.rs index 6b1450d..a686cda 100644 --- a/crates/adapters/tests/rh_signal.rs +++ b/crates/adapters/tests/rh_signal.rs @@ -345,9 +345,16 @@ fn captured_trace_matches_emitted_csv_samples() { } let max = trace.samples.iter().copied().max().unwrap(); let min = trace.samples.iter().copied().min().unwrap(); - // The capture cadence is the configured period; the time base anchors at 0 for the heat. + // 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); - assert_eq!(trace.from, Some(gridfpv_events::SourceTime::from_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 From 337c6f7cd5d9dd0067cc4b6e21b293d506f58263 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 03:31:36 +0000 Subject: [PATCH 244/362] feat(rotorhazard): mock race-day autopilot via the test plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets you drive the GridFPV Director while the gridfpv_mock test plugin emulates realistic races. You stage + start a heat; the autopilot emulates that heat over the wire. - plugins/gridfpv_mock: add `gridfpv_mock_pass {node, peak, baseline, width}` (append a smooth RSSI bell to the node's dense history AND record the lap via intf_simulate_lap, so emulated races show both laps and live RSSI traces) + `gridfpv_mock_reset`. - docker/rotorhazard/race_day.py: the autopilot — connects to RH, watches race_status, and on RACING emulates the seated (tuned) nodes per a scenario, injecting passes via gridfpv_mock_pass; stops when the race ends. Scenarios: clean / varied / messy (marshaling: missed lap, false pass, DNF) / pack (tight finishes). - xtask: `cargo xtask race-day [scenario]` ensures a RotorHazard with both plugins is up (builds the image + docker-runs it if needed; no mock_data CSV — the autopilot is the only signal source) and execs the autopilot in it; `race-day list` shows the menu. Verified in-container end-to-end: starting a race makes the autopilot inject per-node passes + dense RSSI that flow through to pass_record and the gridfpv_signal stream. Gates: cargo xtask ci green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/rotorhazard/race_day.py | 221 +++++++++++++++++++++++++++ plugins/gridfpv_mock/__init__.py | 51 +++++++ xtask/src/main.rs | 6 +- xtask/src/race_day.rs | 246 +++++++++++++++++++++++++++++++ 4 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 docker/rotorhazard/race_day.py create mode 100644 xtask/src/race_day.rs 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/plugins/gridfpv_mock/__init__.py b/plugins/gridfpv_mock/__init__.py index eb10bfa..4275550 100644 --- a/plugins/gridfpv_mock/__init__.py +++ b/plugins/gridfpv_mock/__init__.py @@ -17,10 +17,18 @@ - ``gridfpv_mock_set_rssi`` ``{node, rssi}`` — force a node's ``current_rssi`` (for inspection). - ``gridfpv_mock_lap`` ``{node}`` — inject a real lap via RH's own ``intf_simulate_lap`` (the same proven path RH's built-in ``simulate_lap`` uses), so the pass flows through the genuine pipeline. +- ``gridfpv_mock_pass`` ``{node, peak, baseline, width, sample_ms}`` — emulate one gate pass: append + a smooth RSSI **bell** (baseline→peak→baseline) to the node's dense history (so the live-signal + trace shows the pass) AND record the lap via ``intf_simulate_lap``. This is what the "mock race + day" autopilot uses to emulate a realistic race over the wire. +- ``gridfpv_mock_reset`` ``{node?}`` — clear a node's (or all nodes') dense history + RSSI, between + races. - ``gridfpv_mock_state`` — reply with per-node ``{index, frequency, current_rssi}``. """ import logging +import math +from time import monotonic logger = logging.getLogger(__name__) @@ -75,6 +83,47 @@ def on_lap(data=None): except Exception as ex: # noqa: BLE001 nack("lap", ex) + def on_pass(data=None): + data = data or {} + try: + node = int(data["node"]) + peak = int(data.get("peak", 150)) + baseline = int(data.get("baseline", 70)) + width = max(2, int(data.get("width", 12))) + sample_ms = float(data.get("sample_ms", 15.0)) + n = interface().nodes[node] + # Append a smooth raised-cosine bell (baseline -> peak -> baseline) to the node's dense + # history, so the GridFPV plugin's live-signal stream shows the gate pass as a real + # rise/peak/fall. Timestamps are monotonic seconds (the same clock RH's history uses). + t0 = monotonic() + for i in range(width): + frac = i / (width - 1) + env = 0.5 - 0.5 * math.cos(2.0 * math.pi * frac) # 0..1..0 over the window + val = int(round(baseline + (peak - baseline) * env)) + n.history_values.append(val) + n.history_times.append(t0 + i * (sample_ms / 1000.0)) + n.current_rssi = peak + n.pass_peak_rssi = peak + # Record the lap through RH's genuine pass pipeline (needs the race RACING). + interface().intf_simulate_lap(node, 0) + ack("pass", node=node, peak=peak) + except Exception as ex: # noqa: BLE001 + nack("pass", ex) + + def on_reset(data=None): + data = data or {} + try: + nodes = interface().nodes + targets = [int(data["node"])] if "node" in data else list(range(len(nodes))) + for idx in targets: + nd = nodes[idx] + nd.history_values[:] = [] + nd.history_times[:] = [] + nd.current_rssi = 0 + ack("reset", nodes=targets) + except Exception as ex: # noqa: BLE001 + nack("reset", ex) + def on_state(_data=None): try: nodes = [ @@ -92,5 +141,7 @@ def on_state(_data=None): rhapi.ui.socket_listen("gridfpv_mock_tune", on_tune) rhapi.ui.socket_listen("gridfpv_mock_set_rssi", on_set_rssi) rhapi.ui.socket_listen("gridfpv_mock_lap", on_lap) + rhapi.ui.socket_listen("gridfpv_mock_pass", on_pass) + rhapi.ui.socket_listen("gridfpv_mock_reset", on_reset) rhapi.ui.socket_listen("gridfpv_mock_state", on_state) logger.info("GridFPV mock-control plugin loaded (TEST ONLY) — gridfpv_mock_* handlers registered") diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5f74d45..50959e0 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ //! never drift. Pure std + cargo, so it works the same on Windows/Linux/macOS. #![forbid(unsafe_code)] +mod race_day; mod rh_mock; use std::path::{Path, PathBuf}; @@ -226,9 +227,12 @@ fn main() { // The interactive RotorHazard mock-signal harness (marshaling testing). Needs Docker to // `feed`; `dump`/`list` are plain HTTP/std. See `rh_mock.rs`. "rh-mock" => rh_mock::run(&args[1..]), + // The mock race-day autopilot: emulate races via the gridfpv_mock plugin while you drive + // the Director. See `race_day.rs`. + "race-day" => race_day::run(&args[1..]), other => { eprintln!("unknown task: {other}"); - eprintln!("usage: cargo xtask [ci|fmt|lint|test|gen|live|rh-mock]"); + eprintln!("usage: cargo xtask [ci|fmt|lint|test|gen|live|rh-mock|race-day]"); false } }; diff --git a/xtask/src/race_day.rs b/xtask/src/race_day.rs new file mode 100644 index 0000000..d93003b --- /dev/null +++ b/xtask/src/race_day.rs @@ -0,0 +1,246 @@ +//! `cargo xtask race-day` — the **mock race-day autopilot harness**. +//! +//! Stands up a RotorHazard with the GridFPV plugin **and** the test-only `gridfpv_mock` plugin, then +//! runs the [`race_day.py`](../../docker/rotorhazard/race_day.py) autopilot inside it. The autopilot +//! watches the race state and, whenever you stage + start a heat in the GridFPV Director, emulates +//! that heat's race over the wire (realistic per-pilot laps + RSSI bells via `gridfpv_mock_pass`). +//! You drive the application; the test plugin drives the races. +//! +//! - **`cargo xtask race-day [scenario] [--port P] [--container NAME]`** — ensure the race RH is up +//! (build the image + `docker run` it if needed; no `mock_data` CSV — the autopilot is the only +//! signal source), then exec the autopilot. Point the Director's active-event timer at the printed +//! URL. Ctrl-C stops the autopilot (the RH container is left running for the next scenario). +//! - **`cargo xtask race-day list`** — the scenario menu. +//! +//! Scenarios (race-day "personalities"): `clean`, `varied`, `messy` (marshaling cases), `pack`. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +/// The persistent race-day RH container (distinct from the test harness's `gridfpv-rh-sig-*` and the +/// maintainer's `gridfpv-demo-rh`). Reused across runs; not auto-removed. +const RACE_CONTAINER: &str = "gridfpv-race-rh"; +/// Default host port — matches the timer URL the deploy guide configures in the Director. +const DEFAULT_PORT: u16 = 5055; +/// The RH harness image (built from `docker/rotorhazard/`); same tag the testkit uses. +const RH_IMAGE: &str = "gridfpv-rotorhazard:4.4.0"; + +const SCENARIOS: &[(&str, &str)] = &[ + ( + "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)", + ), +]; + +pub fn run(args: &[String]) -> bool { + let mut scenario = "clean".to_string(); + let mut port = DEFAULT_PORT; + let mut container = RACE_CONTAINER.to_string(); + + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "list" => { + print_menu(); + return true; + } + "--port" => match it.next().and_then(|v| v.parse::().ok()) { + Some(p) => port = p, + None => { + eprintln!("--port needs a number"); + return false; + } + }, + "--container" => match it.next() { + Some(c) => container = c.clone(), + None => { + eprintln!("--container needs a name"); + return false; + } + }, + other if other.starts_with("--") => { + eprintln!("unknown flag: {other}"); + return false; + } + name => scenario = name.to_string(), + } + } + + if !SCENARIOS.iter().any(|(n, _)| *n == scenario) { + eprintln!("unknown scenario: {scenario}"); + print_menu(); + return false; + } + if !docker_present() { + eprintln!("\x1b[31mdocker not found on PATH.\x1b[0m race-day drives a real container."); + return false; + } + + let root = workspace_root(); + if !ensure_image(&root) { + return false; + } + if !ensure_container(&root, &container, port) { + return false; + } + + // Copy the autopilot in fresh (so edits land without rebuilding the container). + let script = root.join("docker/rotorhazard/race_day.py"); + if !run_ok( + "docker", + &[ + "cp", + script.to_str().unwrap(), + &format!("{container}:/tmp/race_day.py"), + ], + ) { + eprintln!("failed to copy race_day.py into {container}"); + return false; + } + + println!( + "\n\x1b[1mMock race day\x1b[0m — scenario \x1b[1m{scenario}\x1b[0m on RotorHazard \ + \x1b[1mhttp://localhost:{port}\x1b[0m (container `{container}`).\n" + ); + println!("\x1b[1mNext steps:\x1b[0m"); + println!( + " 1. In the GridFPV Director, point your active event's RotorHazard timer at \ + \x1b[1mhttp://localhost:{port}\x1b[0m and make the event active (timer reads Connected)." + ); + println!( + " 2. Build an event with pilots + heats, seat pilots on nodes, then \x1b[1mStage → Start\x1b[0m a heat." + ); + println!( + " 3. Watch it race — the autopilot below emulates the seated nodes per the scenario." + ); + println!( + " 4. Marshal / advance as you like; run the next heat. Ctrl-C here ends the autopilot.\n" + ); + + // Exec the autopilot in the foreground (streams its log; runs until Ctrl-C). + run_ok( + "docker", + &["exec", &container, "python3", "/tmp/race_day.py", &scenario], + ) +} + +fn print_menu() { + println!("\n\x1b[1mMock race-day scenarios\x1b[0m (cargo xtask race-day ):\n"); + for (name, blurb) in SCENARIOS { + println!(" \x1b[1m{name:<8}\x1b[0m {blurb}"); + } + println!("\nEach watches the race state and emulates every heat you Start in the Director."); +} + +/// Build the RH harness image if it isn't present locally (same as the testkit's auto-build). +fn ensure_image(root: &Path) -> bool { + let present = Command::new("docker") + .args(["image", "inspect", RH_IMAGE]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if present { + return true; + } + let context = root.join("docker/rotorhazard"); + println!("\x1b[1mBuilding {RH_IMAGE}\x1b[0m (first run only; ~1–2 min)…"); + run_ok( + "docker", + &["build", "-t", RH_IMAGE, context.to_str().unwrap()], + ) +} + +/// Ensure the race RH container is running with BOTH plugins mounted (and no `mock_data` CSV, so the +/// autopilot is the only signal source). Reuses an already-running container; starts one otherwise. +fn ensure_container(root: &Path, container: &str, port: u16) -> bool { + let running = Command::new("docker") + .args(["inspect", "-f", "{{.State.Running}}", container]) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false); + if running { + println!("Reusing running RotorHazard container `{container}`."); + return true; + } + // Remove any stopped leftover of the same name, then start fresh. + let _ = Command::new("docker") + .args(["rm", "-f", container]) + .output(); + + let gridfpv = root.join("plugins/gridfpv"); + let mock = root.join("plugins/gridfpv_mock"); + let mount = |dir: &Path, name: &str| { + format!( + "{}:/opt/RotorHazard/src/server/plugins/{name}:ro", + dir.display() + ) + }; + println!("Starting RotorHazard `{container}` on :{port} (gridfpv + gridfpv_mock plugins)…"); + let ok = run_ok( + "docker", + &[ + "run", + "-d", + "--name", + container, + "-p", + &format!("{port}:5000"), + "-v", + &mount(&gridfpv, "gridfpv"), + "-v", + &mount(&mock, "gridfpv_mock"), + RH_IMAGE, + ], + ); + if !ok { + eprintln!("failed to start {container}"); + return false; + } + // Wait for the HTTP port to accept connections. + let deadline = Instant::now() + Duration::from_secs(60); + while Instant::now() < deadline { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + std::thread::sleep(Duration::from_secs(3)); // settle the socket API + return true; + } + std::thread::sleep(Duration::from_millis(500)); + } + eprintln!("RotorHazard did not open port {port} in time"); + false +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest dir has a parent (workspace root)") + .to_path_buf() +} + +fn docker_present() -> bool { + Command::new("docker") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run_ok(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} From df36041841374d4f16b110787ee8fe75f66e32ea Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 03:50:09 +0000 Subject: [PATCH 245/362] feat(rotorhazard): per-node passes via the plugin (plugin S3, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now emits each pass natively from RotorHazard's RACE_LAP_RECORDED event, attributed by node seat — the Director gets the pass atom directly instead of diffing the current_laps snapshot. Additive and dedup-safe: it coexists with current_laps (same (competitor, lap_number) dedup, so whichever arrives first wins, no double-count) and a stock RH without the plugin still works via current_laps. - plugins/gridfpv: declare the "live_pass" capability; on RACE_LAP_RECORDED broadcast gridfpv_pass {node_index, lap_number, lap_time_stamp, peak_rssi}. - adapter: Raw::GridPass + translate_grid_pass -> Pass (+ CompetitorSeen on a seat's first pass), deduped exactly like the current_laps path; transport listens for gridfpv_pass. Scoped deliberately to the safe, additive half of S3. The "clean_control" half — native race.stage()/stop() replacing the alter_race_format staging-zero + the add_pilot/alter_heat seating dance — is a one-way replacement of working race-running machinery and is staged as a reviewed follow-up (RACE_LAP_RECORDED firing per node without seating, verified here, means passes no longer need that dance — the prerequisite is in place). Gates: cargo xtask ci green; full cargo xtask live green (17/17, plugin mounted); gridfpv_pass verified in-container. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapters/src/rotorhazard.rs | 97 ++++++++++++++++++++ crates/adapters/src/rotorhazard/transport.rs | 22 ++++- plugins/gridfpv/README.md | 9 +- plugins/gridfpv/__init__.py | 37 +++++++- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/crates/adapters/src/rotorhazard.rs b/crates/adapters/src/rotorhazard.rs index b786a60..94b5445 100644 --- a/crates/adapters/src/rotorhazard.rs +++ b/crates/adapters/src/rotorhazard.rs @@ -173,6 +173,10 @@ pub enum Raw { /// 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`]). @@ -546,6 +550,23 @@ pub struct RawGridSignalNode { 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 /// pilot reassignment (the binding to a GridFPV pilot is a registration action, not /// an adapter event — see `gridfpv_events::CompetitorRef`). @@ -1200,6 +1221,37 @@ impl RotorHazardAdapter { } } } + + /// 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, + }; + 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)); + } } impl Default for RotorHazardAdapter { @@ -1234,6 +1286,7 @@ impl Adapter for RotorHazardAdapter { 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 } @@ -2163,6 +2216,50 @@ mod tests { ); } + #[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())); diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index f37ae63..d9f6e7e 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -38,9 +38,9 @@ use rust_socketio::{ClientBuilder, Payload, RawClient}; use serde_json::json; use super::{ - Raw, RawCurrentLaps, RawEnterExitLevels, RawGridSignal, RawHeatData, RawMarshalData, - RawNodeData, RawPassRecord, RawPilotData, RawRaceDetails, RawRaceList, RawRaceStatus, - RotorHazardAdapter, + Raw, RawCurrentLaps, RawEnterExitLevels, RawGridPass, RawGridSignal, RawHeatData, + RawMarshalData, RawNodeData, RawPassRecord, RawPilotData, RawRaceDetails, RawRaceList, + RawRaceStatus, RotorHazardAdapter, }; use crate::Adapter; use gridfpv_events::Event; @@ -90,6 +90,10 @@ pub fn raw_from_socket(event: &str, payload: &Payload) -> Option { "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, } } @@ -410,6 +414,18 @@ impl RotorHazardConnection { 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", { diff --git a/plugins/gridfpv/README.md b/plugins/gridfpv/README.md index e2ad8af..f81399c 100644 --- a/plugins/gridfpv/README.md +++ b/plugins/gridfpv/README.md @@ -30,5 +30,10 @@ Confirm the plugin loads with `cargo xtask rh-mock plugin-check`; drive a live h |-------|------|--------| | S1 | `gridfpv_hello` handshake via `socket_listen`; capabilities/version | ✅ shipped | | S2 | live dense RSSI broadcast (`gridfpv_signal`) — retires save-then-pull | ✅ shipped | -| S3 | clean start/stop (`race.stage()`/`stop()`) + per-node passes | next | -| S4 | threshold recalculate (#3) over the stored dense trace + `frequencyset_alter` | | +| S3 | per-node passes (`gridfpv_pass` from `RACE_LAP_RECORDED`) ✅; native start/stop + delete socket hacks → staged for review | ◐ partial | +| S4 | threshold recalculate (#3) over the stored dense trace + `gridfpv_calibrate` | | + +S3 added the `"live_pass"` capability: the plugin emits each pass natively from +`RACE_LAP_RECORDED`, attributed by node seat (the Director folds it like a `current_laps` +lap, deduped on `lap_number`). The "clean_control" half — native `race.stage()`/`stop()` +replacing the `alter_race_format`/seating socket workarounds — is planned next. diff --git a/plugins/gridfpv/__init__.py b/plugins/gridfpv/__init__.py index 93f3fc0..c40f409 100644 --- a/plugins/gridfpv/__init__.py +++ b/plugins/gridfpv/__init__.py @@ -41,15 +41,17 @@ # manifest.json's "version". PLUGIN_VERSION = "0.1.0" -# Capabilities this build implements — the Director keys transport decisions off these -# (e.g. it prefers the plugin's live-signal path, and skips the post-race pull, once it -# sees "live_signal"). Later slices append "clean_control", "recalc". -CAPABILITIES = ["hello", "live_signal"] +# Capabilities this build implements — the Director keys transport decisions off these. +# "live_pass": the plugin emits per-node passes natively from RACE_LAP_RECORDED (S3), so the +# Director gets the pass atom directly rather than diffing the current_laps snapshot. Native +# start/stop control ("clean_control") lands in a later step. +CAPABILITIES = ["hello", "live_signal", "live_pass"] # Socket.io event names of the gridfpv_* namespace. EVT_HELLO = "gridfpv_hello" EVT_HELLO_ACK = "gridfpv_hello_ack" EVT_SIGNAL = "gridfpv_signal" +EVT_PASS = "gridfpv_pass" # Live-signal broadcast cadence (seconds) — decimated so the stream stays cheap on a Pi # (design risk #5). 0.5 s = 2 Hz. Each tick sends only the NEW dense samples since the last @@ -181,12 +183,37 @@ def stop_signal(_args=None): except Exception: # noqa: BLE001 logger.exception("GridFPV final signal flush error") + # ---- S3: per-node passes ----------------------------------------------------------- + def on_lap_recorded(args=None): + """Broadcast the pass atom natively as RH records each lap (RACE_LAP_RECORDED), attributed by + node seat — so the Director gets passes directly instead of diffing the current_laps snapshot. + `lap` is RH's Crossing object; `lap_time_stamp` is cumulative ms since race start (the same + unit current_laps carries), so the Director folds it identically (and dedups on lap_number).""" + args = args or {} + try: + lap = args.get("lap") + node_index = args.get("node_index") + if lap is None or node_index is None: + return + rhapi.ui.socket_broadcast( + EVT_PASS, + { + "node_index": node_index, + "lap_number": getattr(lap, "lap_number", 0), + "lap_time_stamp": getattr(lap, "lap_time_stamp", 0.0), + "peak_rssi": args.get("peak_rssi"), + }, + ) + except Exception: # noqa: BLE001 - never let a pass broadcast take down RH + logger.exception("GridFPV pass broadcast error") + if Evt is not None: rhapi.events.on(Evt.RACE_START, start_signal, name="gridfpv_signal_start") rhapi.events.on(Evt.RACE_STOP, stop_signal, name="gridfpv_signal_stop") rhapi.events.on(Evt.RACE_FINISH, stop_signal, name="gridfpv_signal_finish") + rhapi.events.on(Evt.RACE_LAP_RECORDED, on_lap_recorded, name="gridfpv_pass") else: # pragma: no cover - logger.warning("GridFPV: eventmanager.Evt unavailable; live signal disabled") + logger.warning("GridFPV: eventmanager.Evt unavailable; live signal/passes disabled") logger.info( "GridFPV plugin loaded (v%s, protocol v%s) — handshake + live signal registered", From f1d0ba0b41065b2a8a06af9b60b7b9b931bef998 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 03:57:26 +0000 Subject: [PATCH 246/362] docs: mock race-day run guide How to drive the GridFPV Director while the gridfpv_mock test plugin emulates races (cargo xtask race-day ). Documents the deployed services, the run flow, the scenario menu (clean/varied/messy/pack), and how to restart the race RH + Director. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mock-race-day.md | 91 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/mock-race-day.md 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 +``` From a089b201c7e256b836e10a5a0a63b0af47eb8b8c Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 04:02:01 +0000 Subject: [PATCH 247/362] =?UTF-8?q?feat(projection):=20threshold-recalcula?= =?UTF-8?q?te=20core=20=E2=80=94=20re-detect=20laps=20over=20a=20trace=20(?= =?UTF-8?q?plugin=20S4,=20part=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3 marshaling recalculate's algorithmic core: replay RH's crossing detection over a captured dense RSSI trace at marshal-chosen enter/exit levels and PROPOSE the resulting laps. RH has no "re-detect at new thresholds" call (the hysteresis is in the node firmware), so this reimplements the enter→peak→exit model as a pure function over the stored SignalHistory (times+rssi). `projection::recalc::redetect_passes(times, rssi, enter, exit) -> Vec`: a crossing opens at rssi>=enter, the pass time is the peak within the crossing, it closes at rssi<=exit; an open crossing at trace end is not a pass (matches RH recording on close). Tested on synthetic bell traces — incl. that a higher enter drops a low false bump (the marshaling win) and that an open-ended crossing is dropped. Scoped to the testable core. NOT wired into the marshaling UI yet, on purpose: the exact pass-time fidelity vs RH's firmware (peak vs centroid) is load-bearing (doc risk #3) and must be validated against real RH first; the draggable-threshold graph + the optional gridfpv_calibrate write-back are the remaining S4 steps, staged for review. Gates: cargo xtask ci green (5 new recalc unit tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/projection/src/lib.rs | 2 + crates/projection/src/recalc.rs | 143 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 crates/projection/src/recalc.rs diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 9012dee..ffa7475 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -30,6 +30,8 @@ //! so a log with no rulings projects identically through either entry point. #![forbid(unsafe_code)] +pub mod recalc; + use std::collections::BTreeMap; use gridfpv_events::{ 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()); + } +} From 7b76e840b1a4168fbf1dd667efc43848c9ad30a3 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 10:58:31 +0000 Subject: [PATCH 248/362] fix(components): buffer backdrop click-to-close so near-misses don't dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal forms (New round, Build a heat, timer/pilot/class create) were dismissing on any backdrop click — easy to trigger accidentally while reaching for the form. Now a backdrop click within 32px of the panel is treated as a near-miss and ignored; only a click well outside closes. Esc + the × + Cancel still close. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/src/primitives/Dialog.svelte | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/packages/components/src/primitives/Dialog.svelte b/frontend/packages/components/src/primitives/Dialog.svelte index d39c96f..08bdd12 100644 --- a/frontend/packages/components/src/primitives/Dialog.svelte +++ b/frontend/packages/components/src/primitives/Dialog.svelte @@ -37,11 +37,28 @@ onclose?.(); } + /** + * Backdrop click-to-close, with a **buffer** so a near-miss while reaching for the form doesn't + * dismiss it. A click whose target is the dialog element itself (not its inner panel) is the + * backdrop region — but we only close when it lands *well outside* the panel: clicks within + * {@link BACKDROP_BUFFER_PX} of the panel edge are treated as intended-for-the-dialog and ignored. + */ + const BACKDROP_BUFFER_PX = 32; function onBackdrop(e: MouseEvent) { if (!dismissable) return; - // A click whose target is the dialog element itself (not its inner panel) is - // the backdrop region. - if (e.target === dialog) close(); + if (e.target !== dialog) return; // clicked inside the panel / its content + const panel = dialog?.querySelector('.gf-dialog-panel'); + if (panel) { + const r = panel.getBoundingClientRect(); + const b = BACKDROP_BUFFER_PX; + const nearPanel = + e.clientX >= r.left - b && + e.clientX <= r.right + b && + e.clientY >= r.top - b && + e.clientY <= r.bottom + b; + if (nearPanel) return; // within the buffer — a near-miss, don't close + } + close(); } From 6b1ff596cb2d6d23ab1b252b4e212b69a5e826c0 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 27 Jun 2026 11:23:01 +0000 Subject: [PATCH 249/362] =?UTF-8?q?feat(rounds):=20open-ended=20Time=20Tri?= =?UTF-8?q?als=20=E2=80=94=20"Heats=20per=20pilot=20=3D=200"=20generates?= =?UTF-8?q?=20the=20next=20heat=20on=20demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set "Heats per pilot" to 0 on a Time Trials (or Round Robin) round to run it open-ended: instead of a fixed number of heats up front, "Generate heats" becomes "Generate next heat" and produces one more heat each click — continuing the channel-balanced rotation indefinitely until the RD stops. The RD ends the round simply by not asking for more. - engine (timed_qual): `rounds == 0` is open-ended — the generator always emits the next round, never Complete (the PerHeat-channel-mode path). - server (round_engine): `static_round_count` no longer clamps to ≥1; `fill_round_static` (the default Static channel-mode path for time-trials) plans one extra rotation round past what's scheduled so it always surfaces a fresh heat, never completing. - frontend (EventRounds): the "rounds" field accepts 0 (min=0) with a "0 = open-ended" hint; an open-ended round single-steps ('Next') with a "Generate next heat" button instead of generating all at once (which would never terminate). Tests: engine `zero_rounds_is_open_ended_and_never_completes`; server `open_ended_static_round_keeps_generating_the_next_heat`. cargo xtask ci green; full cargo xtask live green (full_event_live flaked once on RH-connect, passed on retry). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/engine/src/timed_qual.rs | 30 +++++++- crates/server/src/round_engine.rs | 70 +++++++++++++++++-- .../rd-console/src/screens/EventRounds.svelte | 29 ++++++-- 3 files changed, 117 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 896fa04..d587db4 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -118,7 +118,9 @@ 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. field: Vec, - /// How many rounds the field flies before the format completes. + /// How many rounds the field flies before the format completes. **`0` is open-ended**: the + /// generator keeps emitting the next round on demand forever (the RD ends it by not requesting + /// more) — "Heats per pilot = 0". rounds: usize, /// Which round metric the cross-round aggregation ranks by. metric: QualMetric, @@ -167,9 +169,10 @@ 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. + // (completed.len(), rounds) — no clock, no RNG. `rounds == 0` is **open-ended**: always + // emit the next round (the RD ends the format by not requesting more). let rounds_run = completed.len(); - if rounds_run < self.rounds { + if self.rounds == 0 || rounds_run < self.rounds { let next_round = rounds_run + 1; GeneratorStep::Run(vec![HeatPlan::new( Self::round_id(next_round), @@ -322,6 +325,27 @@ mod tests { assert_eq!(g.next(&completed), GeneratorStep::Complete); } + #[test] + fn zero_rounds_is_open_ended_and_never_completes() { + // "Heats per pilot = 0": the generator keeps emitting the next round 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!("round-{r}"), + field(&["A", "B"]) + )]), + "open-ended (rounds=0) must keep emitting round {r}, never Complete" + ); + completed.push(CompletedHeat::new( + format!("round-{r}"), + best_lap_round(&[("A", Some(2_000_000))]), + )); + } + } + #[test] fn next_is_deterministic_for_the_same_history() { let mut g1 = TimedQualifying::new(field(&["A", "B"]), 3, QualMetric::BestLap); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 592c875..e6a0903 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -1134,13 +1134,27 @@ fn fill_round_static( // 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. + // 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); - let plans = channel_balanced_plan(round, &members, node_cap, format_rounds); + // 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 already: Vec = scheduled_round_heats(events, &round.id); let next = plans.into_iter().find(|(heat, _)| !already.contains(heat)); match next { Some((heat, assignment)) => { @@ -1151,7 +1165,8 @@ fn fill_round_static( frequencies: Some(assignment), }) } - // Every planned channel-balanced heat is already scheduled → the static round is complete. + // 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), } } @@ -1248,6 +1263,11 @@ fn channel_balanced_plan( /// 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, @@ -1258,7 +1278,6 @@ fn static_round_count(round: &RoundDef) -> usize { .get("rounds") .and_then(|v| v.parse().ok()) .unwrap_or(default) - .max(1) } /// Slugify a round id into a heat-id-safe stem (lowercase alnum, other runs → single `-`). @@ -1675,6 +1694,47 @@ mod tests { )); } + #[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] diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 4311192..1d491c3 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -259,11 +259,18 @@ // single-steps (`'Next'`). The engine acks ok whether it appended heat(s) OR reported the round // complete / its outstanding heat unscored, so compare the round's heat count before and after to // tell the RD what happened, then refetch once after the (possibly batched) fill. + // Open-ended round: "Heats per pilot" set to 0 (Time Trials / Round Robin). Instead of a fixed + // set, the round generates the next heat on demand forever — so it single-steps ('Next') like + // Open Practice rather than generating all at once (which would never terminate). + function isOpenEndedRound(round: RoundDef): boolean { + return (round.params?.rounds ?? '') === '0'; + } + async function fillRound(round: RoundDef) { if (fillingRound) return; fillingRound = round.id; const before = heatsByRound(round.id).length; - const generateAll = isDeterministicRound(round); + const generateAll = isDeterministicRound(round) && !isOpenEndedRound(round); try { const ack = await session.fillRound(round.id, generateAll ? 'All' : 'Next'); if (!ack.ok) return; // The error banner / toast surfaces session.lastCommandError. @@ -1164,7 +1171,11 @@ loading={fillingRound === round.id} disabled={fillingRound !== undefined} > - {isDeterministicRound(round) ? 'Generate heats' : 'Add next heat'} + {isOpenEndedRound(round) + ? 'Generate next heat' + : isDeterministicRound(round) + ? 'Generate heats' + : 'Add next heat'} {/if} {/snippet} @@ -1270,7 +1281,11 @@ {:else}

    No heats yet — {isDeterministicRound(round) ? 'Generate heats' : 'Add next heat'}{isOpenEndedRound(round) + ? 'Generate next heat' + : isDeterministicRound(round) + ? 'Generate heats' + : 'Add next heat'} to draw from this round’s field.

    {/if} @@ -1528,7 +1543,12 @@
    {#each formatParams as schema (schema.key)} {@const value = paramValues[schema.key] ?? ''} - + {#if schema.kind === 'bool'}
  • @@ -151,7 +156,7 @@

    Reference

  • - 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.
    @@ -161,7 +166,7 @@

    Reference

    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). + Head-to-Head (decision D17) (structures deferred post-v1).
  • diff --git a/docs/marshaling-plan.html b/docs/marshaling-plan.html index 21a5ff4..be9af71 100644 --- a/docs/marshaling-plan.html +++ b/docs/marshaling-plan.html @@ -104,12 +104,18 @@

    3. The six slices, in recommended order

    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.
    + 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.
    + 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).
    diff --git a/docs/marshaling.html b/docs/marshaling.html
    index 88e2aa7..f6a0e48 100644
    --- a/docs/marshaling.html
    +++ b/docs/marshaling.html
    @@ -227,6 +227,13 @@ 

    3.3 Differentiator — the governance layer nobody has

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

    4. Architectural fit

    diff --git a/docs/pipeline-mains.html b/docs/pipeline-mains.html index df74df6..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 @@ -45,7 +57,8 @@

    Capabilities

    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), a channel mode + 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). diff --git a/docs/pipeline-qualifying.html b/docs/pipeline-qualifying.html index 71f4200..4729f98 100644 --- a/docs/pipeline-qualifying.html +++ b/docs/pipeline-qualifying.html @@ -45,15 +45,17 @@

    Role

    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, created from the Rounds+Heats stage-page like any other round and + 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). Live is the path that shipped; import / skip and the rolling-queue + 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 + timed_qual/round_robin generators; UI: + path + the timed_qual generator; UI: Clients §1 Rounds+Heats.)

    @@ -62,8 +64,9 @@

    Capabilities

    Three ways to get a ranking

      -
    • Run it live (built) — fly a timed_qual (Time Trials) / - round_robin qualifying round and score it.
    • +
    • 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.
    • @@ -92,8 +95,14 @@

      Win condition = qualifying metric (when run live)

      Best lap → best-lap, Best N consecutive → best-consecutive, Timed — Most Laps → most-laps. For - round_robin: Timed → total-laps, otherwise + 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).

    Organizing qualifying flights — multiple heats per pilot

    @@ -105,6 +114,10 @@

    Organizing qualifying flights — multiple heats per pilot

    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).
  • @@ -116,8 +129,9 @@

    Output → seeding

    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 — that is the seed input the - mains are generated from. + 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

    @@ -132,7 +146,7 @@

    Sim vs IRL

    Entities & data implied

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

    -
    Qualifying round (per class) — timed_qual / round_robin
    +
    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 diff --git a/docs/race-engine.html b/docs/race-engine.html index 1cb36ca..0c21aed 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -148,8 +148,10 @@

    3. The format / generator interface

    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 → @@ -184,7 +186,8 @@

    3. The format / generator interface

    The built-in formats

    - The standard registry ships seven formats. Each is identified by a stable + 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.

    @@ -193,15 +196,20 @@

    The built-in formats

    Friendly nameFormat keyShape & config params - Qualifyingtimed_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. - Round Robinround_robinrounds (“Heats per pilot”) × heat_size; the ranking metric is derived from the win condition (Timed → total-laps, else points) — no separate metric param. - ZippyQzippyqRolling / on-demand — the RD adds rounds as pilots are ready (rounds = initial rounds, default 0). - Single Eliminationsingle_elimBracket; heat_size (default 2, head-to-head). - Double Eliminationdouble_elimWinners + losers bracket; bracket_reset. - Multi-Mainmulti_mainTiered A/B/C/… mains; main_size. - Open Practiceopen_practiceOne open heat over the active channels (not pilots); no scoring (see §4). + 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}). +

    + 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 @@ -211,7 +219,9 @@

    The built-in formats

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

    @@ -260,6 +270,14 @@

    Penalties, disqualification & heat-void

    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. diff --git a/docs/roadmap.html b/docs/roadmap.html index c068df8..022198b 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -105,8 +105,11 @@

    2. The phases

    (race engine) are complete and merged — the SQLite append-only log, projection engine, Rust→TS generation, RotorHazard + Velocidrone adapters, recorded-session replay golden tests, live + emulated-signal dockerized-RH tests, the - heat-loop state machine, scoring/marshaling, and the six format generators are all in - the tree. As designed, v0.2 already ingests a live (dockerized) source — "shadow mode." + 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 @@ -196,7 +199,10 @@

    v0.4 — Direct a single race (RD console) — vertical slices

    Deferred out of v0.4: ZippyQ shelved from the active format - set (D10, #218) and PWA self-register; channel + 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 From 78eb6d147be6c7fcd39fca380973d05dc6d39d1f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 02:09:39 +0000 Subject: [PATCH 311/362] fix(results): close the residual defensible-results holes (release review blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 2026-07-03 multi-agent release review — #329 closed the happy path but four confirmed blockers/serious defects remained in the adjudication -> results pipeline: - WINDOW ROUTING: heat_window_offsets attributed every marshaling event positionally, so adjudicating any non-latest heat silently no-opped on the target heat AND penalized pilots in whichever heat was active. Heat-tagged events (PenaltyApplied/HeatVoided/ProtestFiled/tagged LapInserted) now route by tag; target-carrying rulings (void/adjust/split/throw-out/ resolve/reverse) follow their target's window; raw passes stay positional. InsertLap/LapInserted gain an additive optional heat tag (wire-compatible; the console sends it) so insertions route correctly too. - LAP CORRECTIONS NOW SCORE: score_heat_window folds corrected_passes (void/insert/adjust/split) before scoring — previously only the marshaling lap DISPLAY applied them while results/rankings/standings/seeding scored raw passes. Corrected streams order chronologically (new Run::group_corrected) so an inserted mid-timeline pass no longer derives a bogus negative lap. - VOIDED HEATS COUNT FOR NOTHING: completed_heats drops voided heats, so Void heat actually nullifies the result everywhere (ranking, standings, class points, seeding); RulingReversed restores it. - HEAT-ID COLLISIONS: generator heat ids are now round-scoped in the log ({round}-{generator-id}); two rounds of one format in an event can no longer corrupt each other. Legacy unscoped logs keep resolving. - H2H DQ EXCLUSION: a disqualified placement earns no points and bands no placement (mirrors timed_qual, #331). - PARAM CAPS: rotations<=50, timed_qual rounds<=100 (0 stays open-ended) — a raw-API param can no longer allocate unbounded heat plans. - CONSOLE: time penalty can no longer be negative (a credit); insert-lap carries the marshaled heat. Regression tests pin each fix (server windowing/corrections/scoping/protest routing; engine DQ-skip + clamps); contract suite updated for scoped ids. Co-Authored-By: Claude Fable 5 --- bindings/Command.ts | 8 +- bindings/Event.ts | 12 +- crates/engine/src/event.rs | 2 + crates/engine/src/head_to_head.rs | 73 +++- crates/engine/src/scoring.rs | 64 +++- crates/engine/src/timed_qual.rs | 50 ++- crates/events/src/lib.rs | 11 + crates/projection/src/lib.rs | 6 + crates/server/src/app.rs | 86 +++-- crates/server/src/control.rs | 7 + crates/server/src/control_handler.rs | 21 +- crates/server/src/round_engine.rs | 328 ++++++++++++++++-- docs/decisions.html | 4 +- docs/race-engine.html | 2 +- .../apps/rd-console/src/lib/marshaling.ts | 20 +- .../rd-console/src/screens/Marshaling.svelte | 18 +- .../rd-console/tests/MarshalingScreen.test.ts | 6 +- .../apps/rd-console/tests/marshaling.test.ts | 8 +- frontend/contract/head-to-head.contract.ts | 24 +- 19 files changed, 640 insertions(+), 110 deletions(-) diff --git a/bindings/Command.ts b/bindings/Command.ts index eaa8efc..2572004 100644 --- a/bindings/Command.ts +++ b/bindings/Command.ts @@ -156,7 +156,13 @@ competitor: CompetitorRef, /** * When the inserted crossing happened, on the source clock. */ -at: SourceTime, } } | { "AdjustLap": { +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. */ diff --git a/bindings/Event.ts b/bindings/Event.ts index 9b73b10..ee9e746 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -68,7 +68,17 @@ heat: HeatId, * 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, } } | { "LapAdjusted": { target: LogRef, at: SourceTime, } } | { "LapSplit": { +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. */ diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 5454376..3a7a587 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -381,6 +381,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); @@ -426,6 +427,7 @@ mod tests { 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), diff --git a/crates/engine/src/head_to_head.rs b/crates/engine/src/head_to_head.rs index e6e307c..29ad18d 100644 --- a/crates/engine/src/head_to_head.rs +++ b/crates/engine/src/head_to_head.rs @@ -74,6 +74,12 @@ impl HeadToHead { /// 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( @@ -85,7 +91,7 @@ impl HeadToHead { Self { field, group_size: group_size.max(2), - rotations: rotations.max(1), + rotations: rotations.clamp(1, Self::MAX_ROTATIONS), scoring, } } @@ -172,6 +178,12 @@ impl Generator for HeadToHead { for heat in completed { let heat_size = heat.result.places.len(); 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) += @@ -190,6 +202,12 @@ impl Generator for HeadToHead { 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| { @@ -414,6 +432,59 @@ mod tests { 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(); diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs index 7179731..8ced965 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -217,6 +217,27 @@ impl Run { /// 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 @@ -237,9 +258,7 @@ impl Run { 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 { @@ -322,7 +341,7 @@ pub fn race_end_reached(passes: &[Pass], condition: WinCondition, race_start: So /// ranking** (see the module docs). pub fn score(passes: &[Pass], condition: WinCondition, race_start: SourceTime) -> HeatResult { score_inner( - &with_positional_offsets(passes), + Run::group(&with_positional_offsets(passes)), condition, race_start, &Adjudications::default(), @@ -467,12 +486,11 @@ impl Adjudications { /// competitor below every non-disqualified one (flagging [`Placement::disqualified`]), and /// [`Event::HeatVoided`] flags the whole [`HeatResult`] voided. fn score_inner( - passes: &[(u64, Pass)], + runs: Vec, condition: WinCondition, race_start: SourceTime, adj: &Adjudications, ) -> HeatResult { - let runs = Run::group(passes); let mut result = match condition { WinCondition::Timed { window_micros } => score_timed(runs, race_start, window_micros, adj), WinCondition::FirstToLaps { n } => score_first_to_laps(runs, n, adj), @@ -533,13 +551,42 @@ where }) .collect(); score_inner( - &passes, + 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 @@ -557,7 +604,8 @@ pub(crate) fn apply_adjudications( // `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( - passes, + // 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))), diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index ab299b4..991ffec 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -148,6 +148,12 @@ impl TimedQualifying { /// 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 @@ -167,7 +173,13 @@ impl TimedQualifying { ) -> 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, } @@ -525,6 +537,42 @@ mod tests { } } + #[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); diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 28918af..35a35b1 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -692,6 +692,16 @@ 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). /// @@ -936,6 +946,7 @@ mod tests { adapter: AdapterId("rh".into()), competitor: CompetitorRef("node-0".into()), at: SourceTime::from_micros(5_000_000), + heat: None, }, Event::LapAdjusted { target: LogRef(43), diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 8c0ce8c..daa6def 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -319,6 +319,9 @@ where adapter, competitor, at, + // The heat tag routes the insertion into the right heat *window* upstream; + // by the time the fold sees it, it is just a synthetic pass. + heat: _, } => { entries.insert( offset, @@ -1261,6 +1264,7 @@ mod marshaling_tests { adapter: AdapterId(adapter.into()), competitor: CompetitorRef(competitor.into()), at: SourceTime::from_micros(at), + heat: None, } } @@ -1793,6 +1797,7 @@ mod marshaling_tests { adapter: AdapterId("vd".into()), competitor: CompetitorRef("A".into()), at: SourceTime::from_micros(3_000_000), + heat: None, }, ), ( @@ -1960,6 +1965,7 @@ mod marshaling_tests { adapter: AdapterId("vd".into()), competitor: CompetitorRef("A".into()), at: SourceTime::from_micros(3_000_000), + heat: None, }, ), ( diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index f576c37..58838c7 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -70,6 +70,7 @@ //! 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; @@ -80,7 +81,7 @@ 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_with_global_offsets}; +use gridfpv_engine::scoring::{HeatResult, WinCondition, score_corrected_with_global_offsets}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{ LapList, lap_list_marshaled, marshaling_log, registrations, signal_trace, @@ -1749,23 +1750,25 @@ fn now_micros() -> i64 { .unwrap_or(0) } -/// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start. -pub(crate) fn first_pass_at(event: &Event) -> Option { - match event { - Event::Pass(p) if p.gate.is_lap_gate() => Some(p.at), - _ => None, - } -} - -/// Filter the log to a single heat's window, **preserving each event's global append offset**: -/// that heat's scheduling / state-change events, plus all passes and marshaling adjudications -/// that fall *while the heat is the active one*, each paired with its position in the full log. +/// Filter the log to a single heat's window, **preserving each event's global append offset**. /// -/// With one heat per log in the common case this is the whole log; with several heats it -/// scopes passes to the span between this heat's first scheduling/transition and the next -/// heat's. Passes carry no heat id (they are raw observations), so attribution is by -/// position in the log relative to heat-loop events — the same ordering the engine uses to -/// decide which heat consumes a pass (race-engine.html §2). +/// 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 @@ -1773,20 +1776,37 @@ pub(crate) fn first_pass_at(event: &Event) -> Option { /// the *wrong* pass; folding with the real offsets fixes that. pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> { 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() { - match event { + let offset = offset as u64; + let include = match event { Event::HeatScheduled { heat: h, .. } | Event::HeatStateChanged { heat: h, .. } => { active = h == heat; - if active { - window.push((offset as u64, event.clone())); - } + active } - // Passes and adjudications belong to whichever heat is currently active. - _ if active => window.push((offset as u64, event.clone())), - _ => {} + // Heat-tagged marshaling events: by tag, never by position. + Event::HeatVoided { heat: h } + | Event::PenaltyApplied { heat: h, .. } + | Event::ProtestFiled { heat: h, .. } => h == heat, + Event::LapInserted { heat: Some(h), .. } => h == heat, + // Target-carrying rulings: by their target's membership. + Event::DetectionVoided { target } + | Event::LapAdjusted { target, .. } + | Event::LapSplit { target, .. } + | Event::LapThrownOut { target } + | Event::ProtestResolved { target, .. } + | Event::RulingReversed { target } => claimed.contains(&target.0), + // Untagged (passes, legacy insertions, registrations): positional. + _ => active, + }; + if include { + claimed.insert(offset); + window.push((offset, event.clone())); } } window @@ -1817,7 +1837,7 @@ pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { /// 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_with_global_offsets`] under the round's win +/// 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], @@ -1825,15 +1845,23 @@ pub(crate) fn score_heat_window( win_condition: WinCondition, ) -> HeatResult { let heat_offsets = heat_window_offsets(events, heat); - let race_start = heat_offsets + // 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. + let corrected = gridfpv_projection::corrected_passes(heat_offsets.iter().map(|(o, e)| (*o, e))); + let race_start = corrected .iter() - .filter_map(|(_, e)| first_pass_at(e)) + .filter(|(_, p)| p.gate.is_lap_gate()) + .map(|(_, p)| p.at) .min() .unwrap_or(SourceTime::from_micros(0)); - score_with_global_offsets( - heat_offsets.iter().map(|(o, e)| (*o, e)), + score_corrected_with_global_offsets( + &corrected, win_condition, race_start, + heat_offsets.iter().map(|(o, e)| (*o, e)), ) } diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs index cc4bb19..fa54cd9 100644 --- a/crates/server/src/control.rs +++ b/crates/server/src/control.rs @@ -215,6 +215,12 @@ pub enum Command { 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 { @@ -461,6 +467,7 @@ mod tests { 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), diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index cd0759f..23a4c86 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -790,11 +790,21 @@ fn command_to_event(state: &AppState, command: Command) -> Result Ok(Event::LapInserted { - 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); an untagged one is a + // legacy client and attributes positionally, as before. + if let Some(h) = &heat { + require_scheduled_heat(state, h)?; + } + Ok(Event::LapInserted { + adapter, + competitor, + at, + heat, + }) + } Command::VoidHeat { heat } => { require_scheduled_heat(state, &heat)?; Ok(Event::HeatVoided { heat }) @@ -1752,6 +1762,7 @@ mod tests { adapter: AdapterId("vd".into()), competitor: CompetitorRef("A".into()), at: SourceTime::from_micros(3_000_000), + heat: None, }, None, ) diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 129585a..fa56e68 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -671,22 +671,42 @@ pub fn completed_heats(round: &RoundDef, events: &[Event]) -> Vec // 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); - // The generator keys `next`/`ranking` on the heat ids it **emitted**. The kept formats - // log those ids verbatim, so `unscope_heat_id` is a pass-through (the bracket formats that - // round-scoped per-level ids were carved out for the primitives-first release). + // 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). /// -/// The kept formats log the generator's id verbatim, so this is a pass-through. (The bracket -/// formats that scoped per-level heat ids with the round id were carved out for the -/// primitives-first release.) -fn unscope_heat_id(_round: &RoundDef, heat: &HeatId) -> String { - heat.0.clone() +/// 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 @@ -1266,23 +1286,38 @@ fn fill_round_per_heat( // 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. - // Open practice (issue #54): the format generator emits a fixed `"open-practice"` heat - // id, which collides when an event has two open-practice rounds — both would claim the - // same id, so the second round auto-creates no distinct heat. Scope the id to the round - // so each open-practice round gets its own heat; other formats keep the generator's id - // verbatim. Rewritten **before** the dedup so `already`/`scheduled_round_heats` (which - // read the round-scoped id from the log) match it and the auto-create stays idempotent - // per round. + // 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); - let mut plans = plans; - if open_practice { - let scoped = open_practice_heat_id(round_id); - for plan in &mut plans { - plan.heat = scoped.clone(); - } - } + // 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(|p| !already.contains(&p.heat)); + 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 @@ -1481,11 +1516,19 @@ fn static_round_count(round: &RoundDef) -> usize { "timed_qual" | "round_robin" => 3, _ => 1, }; - round + let rounds: usize = round .params .get("rounds") .and_then(|v| v.parse().ok()) - .unwrap_or(default) + .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 `-`). @@ -2342,9 +2385,11 @@ mod tests { } #[test] - fn heat_voided_is_handled_in_the_projections() { - // A voided heat: the projections still compute (no panic) and the heat result is flagged - // voided, so a downstream consumer can nullify it. + 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( @@ -2353,22 +2398,29 @@ mod tests { "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()), }); - // completed_heats flags the void; the standings still resolve over the round. - let completed = completed_heats(&round, &log); + // Voided → the heat contributes nothing: no completed heats, no ranked metrics. assert!( - !completed.is_empty() && completed.iter().all(|h| h.result.voided), - "the heat is flagged voided" + 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_eq!( - standings.len(), - 2, - "standings still computed over a voided heat" + 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] @@ -2446,6 +2498,210 @@ mod tests { ); } + // --- 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 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); + 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); + 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}"); + } + } + /// A `RankEntry` for a competitor at a 1-based position (aggregation test helper). fn rank(competitor: &str, position: u32) -> RankEntry { RankEntry { diff --git a/docs/decisions.html b/docs/decisions.html index 77ba7c0..cac6c81 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -569,7 +569,9 @@

    D18 · One grouping decision per round (extends D17)

    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}. + 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.
    diff --git a/docs/race-engine.html b/docs/race-engine.html index 0c21aed..63bf47c 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -198,7 +198,7 @@

    The built-in formats

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

    diff --git a/frontend/apps/rd-console/src/lib/marshaling.ts b/frontend/apps/rd-console/src/lib/marshaling.ts index 5b7c4a3..7aed369 100644 --- a/frontend/apps/rd-console/src/lib/marshaling.ts +++ b/frontend/apps/rd-console/src/lib/marshaling.ts @@ -30,13 +30,18 @@ export function voidDetectionCommand(target: LogRef): Command { return { VoidDetection: { target } }; } -/** Insert a missed crossing for a competitor at a source-clock time. */ +/** + * Insert a missed crossing for a competitor at a source-clock time. Carries the marshaled + * `heat` so the server routes the insertion into THAT heat's scoring window — marshaling a + * finished heat while a later heat is live must never attribute the lap to the live heat. + */ export function insertLapCommand( adapter: AdapterId, competitor: CompetitorRef, - at: SourceTime + at: SourceTime, + heat: HeatId ): Command { - return { InsertLap: { adapter, competitor, at } }; + return { InsertLap: { adapter, competitor, at, heat } }; } /** Re-time an existing logged pass (identified by its log offset) to a new time. */ @@ -106,9 +111,14 @@ export function resolveProtestCommand(target: LogRef, outcome: ProtestOutcome): return { ResolveProtest: { target, outcome } }; } -/** Build a `TimeAdded` penalty from a whole-second amount (the console's input unit). */ +/** + * Build a `TimeAdded` penalty from a whole-second amount (the console's input unit). A penalty + * only ever WORSENS a result: a non-positive amount clamps to 0µs (a no-op ruling) rather than + * crediting time — a negative "penalty" silently improving a competitor's result is exactly the + * kind of ruling defensible results must not allow. + */ export function timeAddedPenalty(seconds: number): Penalty { - return { TimeAdded: { micros: Math.round(seconds * 1_000_000) } }; + return { TimeAdded: { micros: Math.max(0, Math.round(seconds * 1_000_000)) } }; } /** Build a `PointsDeducted` standings penalty (season/event points, not per-heat). */ diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 16e64a3..2aaa47b 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -270,9 +270,9 @@ } async function doInsertAfterSelected(): Promise { - if (!selected) return; + if (!selected || !heat) return; const ack = await session.send( - insertLapCommand(adapter, selected.competitor, secondsToSourceTime(editSeconds)) + insertLapCommand(adapter, selected.competitor, secondsToSourceTime(editSeconds), heat) ); if (ack.ok) await afterCorrection(); } @@ -288,8 +288,8 @@ /** Add a lap for a competitor at an exact source-clock time (µs) — the graph-click path. */ async function insertLap(competitor: CompetitorRef, at: number): Promise { - if (!canControl) return; - const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at))); + if (!canControl || !heat) return; + const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at), heat)); if (ack.ok) await afterCorrection(); } @@ -322,6 +322,8 @@ let dqReason = $state(''); async function doPenalty(): Promise { if (!heat || !penaltyTarget) return; + // A non-positive time "penalty" would credit time / log a no-op ruling — refuse to send. + if (penaltyKind === 'time' && !(penaltySeconds > 0)) return; let ack; if (penaltyKind === 'points') { // Points affect SEASON/EVENT standings, not the per-heat lap result. @@ -708,7 +710,13 @@ {#if penaltyKind === 'time'} - + + {:else if penaltyKind === 'points'}

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

    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

    From 52b6cc3db9d0f713ce53b21e4892a60c98f69dc3 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 11:15:45 +0000 Subject: [PATCH 313/362] fix(app): auto-official finalize respects the open-protest gate (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-official protest-window driver (spawn_auto_official_driver in crates/app/src/source.rs) appended its Finalized transition directly to the log, bypassing the command handler — so the #330 "cannot finalize: resolve open protests first" guard (P1-4) never applied to it. A protest filed during the protest window could be auto-finalized over, defeating the point of the window. Fix: at window expiry the driver now checks the SAME predicate the manual Finalize command is gated on. open_protest_count (filed protests minus non-reversed resolutions) is promoted to pub in gridfpv_server::control_handler and reused by the driver — one definition of "still contested" for both finalize paths, no duplicated count logic. A log read failure at expiry also stands down (fail closed, never finalize blind). Chosen behavior when protests ARE open at expiry: the driver appends nothing and leaves the heat Unofficial for the RD — no retry loop. Rationale: a filed protest pulls a human into the loop, and the RD's follow-up is often several rulings (resolve, then a penalty, then finalize); an auto-finalize firing at the surprising instant the last protest resolves could race those. The driver is a one-shot task per Unofficial entry (cancelled by the bridge on any transition), so standing down fits its structure with no new lifecycle; the RD's manual Finalize — re-checked by the same gate on the command path — closes the heat with one click. Adds the driver's first integration tests (bridge harness over Practice, as the failover tests): - auto_official_finalizes_after_the_window_with_no_protests: pins the happy path — deadline fact logged, window elapses, heat folds Final. - auto_official_stands_down_on_an_open_protest_until_resolved: window expiry with an open protest appends no Finalized (heat stays Unofficial), manual Finalize is blocked by the same gate, and resolving the protest unblocks the manual Finalize to Final. Gate: cargo test --workspace (855 passed, 0 failed), cargo fmt, cargo clippy --all-targets -- -D warnings clean. Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 264 ++++++++++++++++++++++++++- crates/server/src/control_handler.rs | 12 +- 2 files changed, 272 insertions(+), 4 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 65dc829..73dfbfc 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1278,6 +1278,7 @@ fn seats_of(state: &AppState, registry: &EventRegistry, heat: &HeatId) -> Vec<(u 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. @@ -1592,7 +1593,18 @@ fn now_micros() -> i64 { /// 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). +/// 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 @@ -1628,6 +1640,39 @@ fn spawn_auto_official_driver( 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. If the heat already left Unofficial (a manual early // Finalize, a Revert, an abort), this task has been cancelled by the bridge and never reaches // here — so the auto-finalize never fights a manual action. @@ -2445,6 +2490,223 @@ mod tests { 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, + }), + }; + 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. diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 23a4c86..4b0e1e5 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -699,7 +699,8 @@ fn command_to_event(state: &AppState, command: Command) -> Result 0 { @@ -1023,8 +1024,13 @@ fn require_ruling_target(state: &AppState, target: LogRef) -> Result<(), Protoco /// 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. Used to gate `Finalize`. -fn open_protest_count(events: &[Event], heat: &HeatId) -> usize { +/// 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 From 76666b0e4c879a2b7f8aa7f8d1c264a5dac7e7b8 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 11:19:11 +0000 Subject: [PATCH 314/362] test: drop unreachable fill-outcome arm (-D warnings) Co-Authored-By: Claude Fable 5 --- crates/server/src/round_engine.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 63e9d5b..f591ed1 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2827,7 +2827,6 @@ mod tests { ); } FillOutcome::Complete | FillOutcome::AlreadyScheduled => {} - other => panic!("unexpected fill outcome {other:?}"), } // …while the NOT-yet-filled sibling resolves live and sees the adjudicated carry. assert_eq!( From d73abdef0b97e9c2387a32bd17a4ee6f02fe312f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 11:26:15 +0000 Subject: [PATCH 315/362] fix(console): marshaling friendly names + robustness batch (#337, #340, #341) - (#337) Marshaling audit trail + "Reverse a ruling"/"Resolve" pickers: the server bakes raw log offsets ("(ref N)") into some summaries and names no competitor structurally for lap-/target-addressed rulings. The screen now re-composes each line from the structured fields: a lap-addressed target resolves its competitor through the lap list, a protest-resolution/reversal chases its target audit entry, and the callsign renders via the shared resolver. Picker options read " - . #offset" (offset only a trailing detail); audit lines strip the raw "(ref N)" the same way. - (#340a) Silent [] on directory loads: LiveRaceControl, Marshaling, and the global ContextHeader no longer swallow failed pilots/heats reads into empty arrays (raw refs with no error state). Each tracks a load-error flag, keeps the last good data, shows a visible "Couldn't load - retry" state (+ one toast on the transition into error), and retries via a nonce. - (#340b) Marshaling open-protest count now matches the server's Finalize gate (open_protest_count, control_handler.rs): a filing is closed only by an UNreversed resolution targeting it, so a ProtestResolved later undone by RulingReversed counts the protest as open again - no more Finalize offered client-side only to be rejected by the server. - (#340c) Rounds form: clearing "Laps" (First-to-N / Best-of-N) or the FromRanking "Take top" no longer silently saves 1 - both block submit via the same canSubmit pattern as the timed race-time field (#329). - (#341, verified) Results JSON export: the legacy standings / heatResult sections carried raw competitor refs. Both now resolve through the shared resolver with the raw ref kept alongside (competitor_ref), like every other exported view. Tests: new/updated coverage in MarshalingScreen, LiveRaceControl, ContextHeader, EventRounds, and results tests; test-session pilot/heat seams default to inert success so the new error state only shows when a test injects a failure. Both svelte-check passes, vitest (491), and lint clean. Co-Authored-By: Claude Fable 5 --- .../apps/rd-console/src/ContextHeader.svelte | 46 ++++- frontend/apps/rd-console/src/lib/results.ts | 43 ++++- .../rd-console/src/screens/EventRounds.svelte | 17 +- .../src/screens/LiveRaceControl.svelte | 57 +++++- .../rd-console/src/screens/Marshaling.svelte | 165 ++++++++++++++++-- .../rd-console/src/screens/Results.svelte | 2 +- .../rd-console/tests/ContextHeader.test.ts | 28 ++- .../apps/rd-console/tests/EventRounds.test.ts | 60 +++++++ .../rd-console/tests/LiveRaceControl.test.ts | 32 ++++ .../rd-console/tests/MarshalingScreen.test.ts | 164 ++++++++++++++++- .../apps/rd-console/tests/results.test.ts | 37 +++- frontend/apps/rd-console/tests/support.ts | 11 +- 12 files changed, 615 insertions(+), 47 deletions(-) diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index 47041f3..6b61b33 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -21,7 +21,7 @@ * freezes on Unofficial/Final, and resets when there's no live heat — everywhere, not just * on the live screen, which now drives its clock from the same source. */ - import { StatusPill, RaceClock } from '@gridfpv/components'; + import { StatusPill, RaceClock, toast } from '@gridfpv/components'; import type { HeatSummary } from '@gridfpv/types'; import type { Session } from './lib/session.svelte.js'; import { useRaceClock } from './lib/raceClock.svelte.js'; @@ -58,13 +58,27 @@ // Touching `currentEvent` makes it re-read the instant the event settles. Rounds come straight off // the event, so the `$derived` name re-resolves the moment either heats or the event changes. let heats = $state([]); + // A FAILED heats read must be visible (#340): swallowing it into an empty array made the header + // silently render the raw heat id with no hint anything was wrong. Track a load-error flag + // (keeping the last good data rather than wiping it), show a compact "Couldn't load — retry" + // state in place of the heat name, and toast once on the transition into the error state. + let heatsError = $state(false); + let heatsRetryNonce = $state(0); + function retryHeats(): void { + heatsRetryNonce += 1; + } $effect(() => { void session.currentEvent; void session.protocolState; + void heatsRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + if (!heatsError) + toast.error('Couldn’t load the heats directory — heat names may not resolve.'); + heatsError = true; + }); }); const heatName = $derived( heat ? heatNameById(heat, heats, session.currentEvent?.rounds ?? []) : '' @@ -112,7 +126,19 @@
    Heat - {heatName} + {#if heatsError} + + + {:else} + {heatName} + {/if}
    {#if phase} @@ -240,6 +266,18 @@ font-size: var(--gf-font-size-sm); color: var(--gf-text-faint); } + /* The heats-directory load-error state (#340): compact, in place of the heat name. */ + .ctx-load-error { + background: var(--gf-danger-soft); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-sm); + padding: var(--gf-space-1) var(--gf-space-2); + color: var(--gf-text); + font-family: inherit; + font-size: var(--gf-font-size-xs); + cursor: pointer; + white-space: nowrap; + } .ctx-phase { display: inline-flex; } diff --git a/frontend/apps/rd-console/src/lib/results.ts b/frontend/apps/rd-console/src/lib/results.ts index 13fa991..7c148c7 100644 --- a/frontend/apps/rd-console/src/lib/results.ts +++ b/frontend/apps/rd-console/src/lib/results.ts @@ -5,7 +5,13 @@ * refs resolved to callsigns and class/round ids to their labels (CLAUDE.md: never leak a raw id) — * while preserving the raw ref alongside so it stays traceable. */ -import type { ClassStanding, CompetitorRef, HeatResult, RankEntry } from '@gridfpv/types'; +import type { + ClassStanding, + CompetitorRef, + HeatResult, + Placement, + RankEntry +} from '@gridfpv/types'; /** Serialize a value to pretty JSON; the bigint replacer is a defensive no-op now * that wire numerics are plain `number`s. */ @@ -16,13 +22,26 @@ export function toExportJson(value: unknown): string { /** A standings/ranking row with its competitor ref resolved to a friendly name (raw ref kept). */ type WithName = Omit & { competitor: string; competitor_ref: CompetitorRef }; +/** + * A scored heat's placement with the competitor resolved to its friendly name. The raw + * source-local ref stays alongside as `competitor_ref` (the same traceability convention as the + * standings rows), replacing the wire's `CompetitorKey`. + */ +type PlacementWithName = Omit & { + competitor: string; + competitor_ref: CompetitorRef; +}; + +/** A {@link HeatResult} with every placement's competitor resolved (raw ref kept alongside). */ +export type HeatResultExport = Omit & { places: PlacementWithName[] }; + /** The friendly, human-usable results payload (whichever views are present). */ export interface ResultsExport { class_standings?: { class: string; standings: WithName[] }; round_ranking?: { round: string; ranking: WithName[] }; - /** The legacy event-level projection, carried through as-is. */ - standings?: RankEntry[]; - heatResult?: HeatResult; + /** The legacy event-level projection — competitor refs resolved like every other view (#341). */ + standings?: WithName[]; + heatResult?: HeatResultExport; } /** The inputs the Results screen passes to {@link buildResultsExport}. */ @@ -60,8 +79,20 @@ export function buildResultsExport(input: ResultsExportInput): ResultsExport { if (input.roundRanking) { out.round_ranking = { round: input.roundLabel ?? '—', ranking: withName(input.roundRanking) }; } - if (input.standings) out.standings = input.standings; - if (input.heatResult) out.heatResult = input.heatResult; + // The legacy event-level projections resolve too (#341): the standings rows through the same + // `withName`, and the heat result's placements — whose wire competitor is a `CompetitorKey` + // (`{ adapter, competitor }`) — through the resolver on the key's ref, raw ref kept alongside. + if (input.standings) out.standings = withName(input.standings); + if (input.heatResult) { + out.heatResult = { + ...input.heatResult, + places: input.heatResult.places.map((p) => ({ + ...p, + competitor: input.resolveCompetitor(p.competitor.competitor), + competitor_ref: p.competitor.competitor + })) + }; + } return out; } diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 7f82f27..dd2f466 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -903,11 +903,18 @@ // is always timed): both read `winSeconds` and would build a NaN/0-µs window if it were left blank. const needsRaceTime = $derived(winKind === 'Timed' || winKind === 'BestOfN'); const raceTimeValid = $derived(Number.isFinite(Number(winSeconds)) && Number(winSeconds) >= 1); + // The "Laps" field backs First-to-N and Best-of-N: clearing it left `winLaps` blank and the + // builder silently clamped it to 1 (#340) — block submit instead, the race-time pattern (#329). + const needsLaps = $derived(winKind === 'FirstToLaps' || winKind === 'BestOfN'); + const lapsValid = $derived(Number.isFinite(Number(winLaps)) && Number(winLaps) >= 1); + // Same for the FromRanking "Take top" cut: a cleared field silently saved `top_n: 1` (#340). + const seedTopNValid = $derived(Number.isFinite(Number(seedTopN)) && Number(seedTopN) >= 1); // The form is submittable once it has a label, a single eligible class, a format, and — when - // seeding from a ranking — at least one chosen source round (the multi-select, issue #51). When the - // win condition is timed (Timed / Best-of-N) a valid race time is also required (else the heat - // would run forever / build a degenerate window). + // seeding from a ranking — at least one chosen source round (the multi-select, issue #51) plus a + // valid "Take top" cut. When the win condition is timed (Timed / Best-of-N) a valid race time is + // also required (else the heat would run forever / build a degenerate window), and a lap-target + // condition (First-to-N / Best-of-N) requires a valid lap count (else 1 would silently save). const canSubmit = $derived( isOpenPractice ? canSubmitOpenPractice @@ -915,7 +922,9 @@ selectedClass !== '' && format.length > 0 && (!needsRaceTime || raceTimeValid) && - (seedKind === 'FromRoster' || (seedKind === 'FromRanking' && seedSources.size > 0)) + (!needsLaps || lapsValid) && + (seedKind === 'FromRoster' || + (seedKind === 'FromRanking' && seedSources.size > 0 && seedTopNValid)) ); async function submit() { diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index f9ad996..f98eecb 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -70,6 +70,22 @@ // re-fetched whenever the stream advances (so a freshly-staged OR freshly-scheduled heat appears). let catalog = $state([]); let heats = $state([]); + // A FAILED pilots/heats directory read must be visible (#340): swallowing it into an empty array + // left every ref/heat-id rendering raw with no hint anything was wrong. Track a load-error flag + // per read (keeping the last good data rather than wiping it), surface a "Couldn't load — retry" + // state + a toast (the Results-screen pattern), and let the RD retry explicitly via the nonce. + let pilotsError = $state(false); + let heatsError = $state(false); + let directoryRetryNonce = $state(0); + const directoryError = $derived(pilotsError || heatsError); + function retryDirectory(): void { + directoryRetryNonce += 1; + } + /** Toast once on the transition INTO the error state (the effects re-run on every stream tick). */ + function noteDirectoryError(alreadyFailing: boolean): void { + if (!alreadyFailing) + toast.error('Couldn’t load the pilot/heat directory — names may show as raw ids.'); + } $effect(() => { session .listChannels() @@ -84,10 +100,14 @@ // (the backend force-emits one when a heat is scheduled), so touching it refreshes the picker the // moment a heat appears — without changing `current_heat` (no focus steal). void session.protocolState; + void directoryRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + heatsError = true; + }); }); // The current heat's ref → channel-label map (race redesign Slice 4b). Empty for a sim/free-text @@ -111,10 +131,14 @@ let pilots = $state([]); $effect(() => { void session.protocolState; + void directoryRetryNonce; session .listPilots() - .then((p) => (pilots = p)) - .catch(() => (pilots = [])); + .then((p) => ((pilots = p), (pilotsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + pilotsError = true; + }); }); const pilotById = $derived(new Map(pilots.map((p) => [p.id, p]))); // A competitor ref → its bound pilot id from the live `progress`, which carries an **explicit** @@ -636,6 +660,15 @@ session.clearCommandError()} /> {/if} + {#if directoryError} + + + {/if} + {#if canControl}
    Transitions @@ -898,6 +931,22 @@ } /* ── Transition controls ─────────────────────────────────────────────────── */ + /* The directory-load error state (#340): visible, with an explicit retry. */ + .dir-error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-md); + background: var(--gf-danger-soft); + } + .dir-error p { + margin: 0; + color: var(--gf-text); + font-size: var(--gf-font-size-sm); + } .controls { display: flex; flex-direction: column; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 2aaa47b..fbcc05a 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -31,7 +31,7 @@ PilotProgress, SignalTraceView } from '@gridfpv/types'; - import { formatMicros, Select } from '@gridfpv/components'; + import { formatMicros, Select, toast } from '@gridfpv/components'; import { channelLabel } from '../lib/channels.js'; import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { heatNameById } from '../lib/heats.js'; @@ -125,21 +125,45 @@ let pilots = $state([]); let heats = $state([]); let catalog = $state([]); + // A FAILED pilots/heats directory read must be visible (#340): swallowing it into an empty array + // left every ref rendering raw with no hint anything was wrong. Track a load-error flag per read + // (keeping the last good data rather than wiping it), surface a "Couldn't load — retry" state + + // a toast (the Results-screen pattern), and let the RD retry explicitly via the nonce. + let pilotsError = $state(false); + let heatsError = $state(false); + let directoryRetryNonce = $state(0); + const directoryError = $derived(pilotsError || heatsError); + function retryDirectory(): void { + directoryRetryNonce += 1; + } + /** Toast once on the transition INTO the error state (the effects re-run on every stream tick). */ + function noteDirectoryError(alreadyFailing: boolean): void { + if (!alreadyFailing) + toast.error('Couldn’t load the pilot/heat directory — names may show as raw ids.'); + } $effect(() => { void session.currentEvent; void session.protocolState; + void directoryRetryNonce; session .listPilots() - .then((p) => (pilots = p)) - .catch(() => (pilots = [])); + .then((p) => ((pilots = p), (pilotsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + pilotsError = true; + }); }); $effect(() => { void session.currentEvent; void session.protocolState; + void directoryRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + heatsError = true; + }); }); $effect(() => { session @@ -374,12 +398,33 @@ } // Filed protests are resolvable by their log offset (the audit entry's `at_ref`). const filedProtests = $derived((audit ?? []).filter((e) => e.kind === 'ProtestFiled')); - const resolvedProtests = $derived((audit ?? []).filter((e) => e.kind === 'ProtestResolved')); - // Open (unresolved) protests = filed minus resolved (the audit carries no filed→resolved link, so - // this is a count). Finalizing while a protest is open would lock a result that's still under - // dispute, so the Finalize action is gated on this being zero (a result isn't "defensible" with an - // open protest). Clamped ≥ 0 defensively. - const openProtestCount = $derived(Math.max(0, filedProtests.length - resolvedProtests.length)); + // Ruling offsets a later `RulingReversed` undid. The audit entry carries its target only inside + // the server-baked summary ("Ruling reversed (ref N)"), so it is parsed back out here. + const reversedRulingTargets = $derived.by(() => { + const targets = new Set(); + for (const e of audit ?? []) { + if (e.kind !== 'RulingReversed') continue; + const t = summaryTargetRef(e.summary); + if (t !== undefined) targets.add(t); + } + return targets; + }); + // Open (unresolved) protests, matching the server's Finalize gate (`open_protest_count`, + // control_handler.rs — the source of truth): a filing is closed only by an EFFECTIVE resolution — + // a `ProtestResolved` targeting it that was NOT itself undone by a `RulingReversed`. The old + // filed-minus-resolved count diverged the moment a resolution was reversed: the server counted + // the protest as open again and rejected Finalize while the UI still offered it (#340). + // Finalizing while a protest is open would lock a result that's still under dispute, so the + // Finalize action is gated on this being zero. + const openProtestCount = $derived.by(() => { + const resolvedFilings = new Set(); + for (const e of audit ?? []) { + if (e.kind !== 'ProtestResolved' || reversedRulingTargets.has(e.at_ref)) continue; + const t = summaryTargetRef(e.summary); + if (t !== undefined) resolvedFilings.add(t); + } + return filedProtests.filter((e) => !resolvedFilings.has(e.at_ref)).length; + }); let resolveProtestRef = $state(''); let protestOutcome = $state('Upheld'); async function doResolveProtest(): Promise { @@ -483,13 +528,70 @@ return new Date(at / 1000).toLocaleTimeString(); } - // The displayed audit line: the server-baked `summary` (which no longer carries the raw competitor - // ref) with the **resolved callsign** prepended when the entry names a competitor. This is the - // client-side composition the AuditEntry restructure enables — a baked raw-id string couldn't be - // re-resolved, so the structured `competitor` ref is resolved here and joined to the summary. + // ── Recomposing the server-baked summaries (#337) ── + // The server interpolates raw LOG OFFSETS into some summaries ("Lap thrown out (ref 14)") because + // it can't resolve anything friendlier. The client can: a lap-addressed ruling targets a pass ref + // the lap list still carries, and a protest-resolution / reversal targets another audit entry + // (whose own `competitor` is structured). So the rendered line re-composes from the structured + // fields — resolved callsign first, the offset only as a trailing "· #N" detail — instead of + // printing the server's raw-ref string. + + /** The target log offset a server summary interpolates ("(ref 42)"), if any. */ + function summaryTargetRef(summary: string): number | undefined { + const m = /\(ref (\d+)\)/.exec(summary); + return m ? Number(m[1]) : undefined; + } + /** The summary with the raw "(ref N)" clause removed (the offset renders as a trailing detail). */ + function stripRefClause(summary: string): string { + return summary.replace(/\s*\(ref \d+\)/, ''); + } + + const auditByRef = $derived(new Map((audit ?? []).map((e) => [e.at_ref, e]))); + + /** The competitor whose lap a pass offset (a lap's start/end ref) bounds, from the lap list. */ + function competitorForPassRef(target: number): CompetitorRef | undefined { + for (const cl of laps?.competitors ?? []) + for (const l of cl.laps) + if (l.end_ref === target || l.start_ref === target) return cl.competitor.competitor; + return undefined; + } + + /** + * The competitor an audit entry concerns: its own structured ref when the action named one, else + * chased through the entry's target — a `ProtestResolved` / `RulingReversed` targets another + * audit entry (follow the chain), a lap-addressed ruling targets a pass in the lap list. + * `undefined` when nothing resolves (a heat-void, or a voided pass no longer in the lap list) — + * the line then renders without a name rather than with a raw ref. + */ + function auditCompetitor(entry: AuditEntry, depth = 0): CompetitorRef | undefined { + if (entry.competitor != null) return entry.competitor; + const target = summaryTargetRef(entry.summary); + if (target === undefined) return undefined; + const chained = auditByRef.get(target); + if (chained && depth < 4) return auditCompetitor(chained, depth + 1); + return competitorForPassRef(target); + } + + // The displayed audit line: the **resolved callsign** (from the structured `competitor` ref, or + // chased through the entry's target) joined to the summary with any server-baked raw "(ref N)" + // stripped; the target offset renders only as a trailing "· #N" detail. A baked raw-id string + // couldn't be re-resolved, so the composition happens here, client-side. function auditSummary(entry: AuditEntry): string { - if (entry.competitor == null) return entry.summary; - return `${competitorName(entry.competitor)} · ${entry.summary}`; + const ref = auditCompetitor(entry); + const target = summaryTargetRef(entry.summary); + const text = stripRefClause(entry.summary); + const line = ref != null ? `${competitorName(ref)} · ${text}` : text; + return target !== undefined ? `${line} · #${target}` : line; + } + + // A ruling/protest picker option: "‹what› — ‹callsign› · #‹offset›". The action + callsign is the + // primary label; the entry's own log offset (what the reverse/resolve command targets) is only a + // trailing detail, never the label itself (#337). + function rulingOptionLabel(entry: AuditEntry): string { + const ref = auditCompetitor(entry); + const text = stripRefClause(entry.summary); + const who = ref != null ? ` — ${competitorName(ref)}` : ''; + return `${text}${who} · #${entry.at_ref}`; } @@ -522,6 +624,15 @@ session.clearCommandError()} /> {/if} + {#if directoryError} + + + {/if} +
    {#if heats.length > 0} @@ -752,7 +863,7 @@ @@ -797,7 +908,7 @@ @@ -922,6 +1033,22 @@ font-size: var(--gf-font-size-sm); margin: var(--gf-space-1) 0 0; } + /* The directory-load error state (#340): visible, with an explicit retry. */ + .dir-error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-md); + background: var(--gf-danger-soft); + } + .dir-error p { + margin: 0; + color: var(--gf-text); + font-size: var(--gf-font-size-sm); + } .layout { display: grid; grid-template-columns: 2fr 1fr; diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index d0d0fe0..8e693dd 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -311,7 +311,7 @@ // Export the CURRENT views with FRIENDLY names baked in (P1-2): competitor refs → callsigns and // class/round ids → their labels, so the JSON is human-usable, not a raw-ref wire dump. The raw ref // is preserved alongside (`competitor_ref`) so the export stays traceable. The legacy event-level - // projection (`heatResult` / `standings`) is carried through as well. + // projection (`heatResult` / `standings`) resolves the same way (#341). function exportAll() { const payload = buildResultsExport({ resolveCompetitor: resolveName, diff --git a/frontend/apps/rd-console/tests/ContextHeader.test.ts b/frontend/apps/rd-console/tests/ContextHeader.test.ts index 0738a1a..beab538 100644 --- a/frontend/apps/rd-console/tests/ContextHeader.test.ts +++ b/frontend/apps/rd-console/tests/ContextHeader.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/svelte'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { tick } from 'svelte'; import type { EventMeta, HeatSummary, LiveRaceState, RoundDef } from '@gridfpv/types'; import ContextHeader from '../src/ContextHeader.svelte'; @@ -157,4 +157,30 @@ describe('ContextHeader heat name', () => { await waitFor(() => expect(heatId()?.textContent).toBe('Qualifying R1 Heat 1')); expect(heatId()?.textContent).not.toContain('q1-heat'); }); + + // #340: a FAILED heats read used to swallow into an empty list, so the header silently rendered + // the raw heat id with no hint anything was wrong. It must show a visible error state (with an + // explicit retry) in place of the heat name instead. + it('shows an error state with retry — not the raw heat id — when the heats read fails (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: EVENT, + live: live(), + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [HEAT]; + }) + }); + render(ContextHeader, { session, ongolive: () => {}, onswitchevent: () => {} }); + + // The error state renders in place of the heat name — the raw id never reaches the screen. + const retry = await screen.findByRole('button', { name: /Couldn.t load — retry/ }); + expect(heatId()).toBeNull(); + + // Retry with the read healthy again: the friendly name resolves. + fail = false; + await fireEvent.click(retry); + await waitFor(() => expect(heatId()?.textContent).toBe('Qualifying R1 Heat 1')); + expect(screen.queryByRole('button', { name: /Couldn.t load — retry/ })).toBeNull(); + }); }); diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index ae50e02..0e5cb8c 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -1007,6 +1007,66 @@ describe('EventRounds (open practice — no win condition + time limit)', () => await fireEvent.input(raceTime, { target: { value: '90' } }); expect(addBtn().disabled).toBe(false); }); + + it('blocks submit when a lap-target win condition has its Laps cleared (#340)', async () => { + const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); + const { session } = makeTestSession({ + ...baseImpls(), + createRoundImpl, + event: { ...EVENT, rounds: [] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { + target: { value: 'Best of 3' } + }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); + await fireEvent.change(screen.getByLabelText('Eligible class'), { target: { value: 'c1' } }); + // Best-of-N reveals the "Laps" field (N), seeded valid (3). + await fireEvent.change(screen.getByLabelText('Win condition'), { + target: { value: 'BestOfN' } + }); + const laps = await screen.findByLabelText('Laps'); + const addBtn = () => screen.getByRole('button', { name: 'Add round' }) as HTMLButtonElement; + expect(addBtn().disabled).toBe(false); + + // Clearing the Laps field blocks submit — it used to silently save n = 1 (#340). + await fireEvent.input(laps, { target: { value: '' } }); + expect(addBtn().disabled).toBe(true); + await fireEvent.click(addBtn()); + expect(createRoundImpl).not.toHaveBeenCalled(); + + // Restoring a valid lap count re-enables submit. + await fireEvent.input(laps, { target: { value: '3' } }); + expect(addBtn().disabled).toBe(false); + }); + + it('blocks submit when the FromRanking "Take top" is cleared (#340)', async () => { + const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); + const { session } = makeTestSession({ ...baseImpls(), createRoundImpl, event: EVENT }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Mains' } }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'single_elim' } }); + await fireEvent.change(screen.getByLabelText('Eligible class'), { target: { value: 'c1' } }); + await fireEvent.change(screen.getByLabelText('Seeding'), { target: { value: 'FromRanking' } }); + await fireEvent.click(await screen.findByLabelText('Seed from Qualifying R1')); + const topN = screen.getByLabelText('Top N'); + const addBtn = () => screen.getByRole('button', { name: 'Add round' }) as HTMLButtonElement; + expect(addBtn().disabled).toBe(false); + + // Clearing "Take top" blocks submit — it used to silently save top_n = 1 (#340). + await fireEvent.input(topN, { target: { value: '' } }); + expect(addBtn().disabled).toBe(true); + await fireEvent.click(addBtn()); + expect(createRoundImpl).not.toHaveBeenCalled(); + + // Restoring a valid cut re-enables submit. + await fireEvent.input(topN, { target: { value: '2' } }); + expect(addBtn().disabled).toBe(false); + }); }); describe('EventRounds (Heats — fill round, heats list, manual build)', () => { diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 12b2e19..bba6a21 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -985,6 +985,38 @@ describe('LiveRaceControl — friendly names (no raw ids/refs)', () => { expect(within(standing).queryByText('node-0')).not.toBeInTheDocument(); expect(within(standing).queryByText('node-1')).not.toBeInTheDocument(); }); + + // #340: a FAILED pilots/heats read used to swallow into empty arrays, so the raw refs rendered + // with no error state. It must surface a visible "Couldn't load — retry" state instead. + it('surfaces a visible retry state when the pilot/heat directory reads fail (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: FN_EVENT, + live: fnLive, + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [HEAT_1_FREQ, HEAT_2]; + }), + listPilotsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return PILOTS as unknown as never; + }), + listChannelsImpl: vi.fn(async () => FN_CATALOG), + listTimersImpl: vi.fn(async () => [FN_TIMER]) + }); + render(LiveRaceControl, { session }); + + // The failure is visible — no more silently-empty directory + raw refs. + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the pilot\/heat directory/); + + // Retry with the reads healthy again: the error clears and the names resolve. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + const title = document.querySelector('.heat-id .value') as HTMLElement; + await waitFor(() => expect(title.textContent?.trim()).toBe('Qualifying R1 Heat 1')); + }); }); // ── Roster-seeded pilot callsigns resolve from the roster binding, BEFORE the heat runs ────────── diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index d2fee18..ad9ddbb 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -293,7 +293,8 @@ describe('Marshaling (Slice 3)', () => { phase: 'Unofficial', lifecycle: { Provisional: {} } }; - // A filed protest AND its resolution → no open protests. + // A filed protest AND its resolution (the server-baked summary carries the filing's offset, + // which the open-count derivation matches against) → no open protests. const audit: AuditEntry[] = [ { kind: 'ProtestFiled', @@ -306,8 +307,8 @@ describe('Marshaling (Slice 3)', () => { kind: 'ProtestResolved', at: 1_700_000_000_000_001, at_ref: 23, - competitor: 'BOB', - summary: 'Protest resolved: denied' + competitor: null, + summary: 'Protest denied (ref 22)' } ]; const { session, sendSpy } = makeTestSession({ live, laps: lapList, audit }); @@ -319,6 +320,50 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ Finalize: { heat: 'heat-1' } }); }); + it('counts a protest as OPEN again when its resolution was reversed (#340, matches the server gate)', async () => { + const live: LiveRaceState = { + ...liveRunning, + phase: 'Unofficial', + lifecycle: { Provisional: {} } + }; + // Filed (#22) → resolved (#23, targeting #22) → the RESOLUTION reversed (#24, targeting #23). + // The server's Finalize gate (`open_protest_count`, control_handler.rs) counts the protest as + // open again; the old filed-minus-resolved UI count offered Finalize and the server rejected it. + const audit: AuditEntry[] = [ + { + kind: 'ProtestFiled', + at: 1_700_000_000_000_000, + at_ref: 22, + competitor: 'BOB', + summary: 'Protest filed: contact' + }, + { + kind: 'ProtestResolved', + at: 1_700_000_000_000_001, + at_ref: 23, + competitor: null, + summary: 'Protest denied (ref 22)' + }, + { + kind: 'RulingReversed', + at: 1_700_000_000_000_002, + at_ref: 24, + competitor: null, + summary: 'Ruling reversed (ref 23)' + } + ]; + const { session, sendSpy } = makeTestSession({ live, laps: lapList, audit }); + render(Marshaling, { session }); + + const finalize = screen.getByRole('button', { name: /Finalize/ }) as HTMLButtonElement; + expect(finalize.disabled).toBe(true); + expect(screen.getByText(/Resolve 1 open protest/)).toBeInTheDocument(); + await fireEvent.click(finalize); + expect( + sendSpy.mock.calls.find(([c]) => typeof c === 'object' && c !== null && 'Finalize' in c) + ).toBeUndefined(); + }); + it('reverts a finalized marshaled heat (Final→Unofficial) after confirm', async () => { const live: LiveRaceState = { ...liveRunning, phase: 'Final', lifecycle: 'Official' }; const { session, sendSpy } = makeTestSession({ live, laps: lapList }); @@ -341,7 +386,11 @@ describe('Marshaling (Slice 3)', () => { // Newest first: the DQ (at_ref 20) precedes the void (at_ref 18). The competitor name is composed // from the STRUCTURED ref (resolved to its callsign — here the bare ref, no directory seeded). expect(entries[0]).toHaveTextContent('CARMEN · DQ applied'); - expect(entries[1]).toHaveTextContent('Detection voided (ref 12)'); + // The void names no competitor structurally, but its target (ref 12) is ALICE's lap-1 end pass + // in the lap list — the line resolves her name and renders the raw "(ref 12)" only as the + // trailing "· #12" detail (#337). + expect(entries[1]).toHaveTextContent('ALICE · Detection voided · #12'); + expect(entries[1]).not.toHaveTextContent('(ref 12)'); }); // ── Slice 4: the signal-as-evidence RSSI graph ──────────────────────────────────────── @@ -659,6 +708,113 @@ describe('Marshaling (Slice 3)', () => { expect(panel.queryByText(/maverick-4d9rp8/)).not.toBeInTheDocument(); }); + // ── #337: the audit + "Reverse a ruling" picker leaked raw log offsets / unresolved refs ────── + // + // The server bakes "(ref N)" into some summaries and names no competitor structurally for the + // lap-/target-addressed rulings. The screen must re-compose the line from the structured fields: + // a lap-addressed target resolves through the lap list, a protest-resolution / reversal chases + // its target audit entry — so the picker reads "‹what› — ‹callsign› · #‹offset›", never "(ref N)". + it('labels the reverse-ruling picker "‹what› — ‹callsign›" with the offset only trailing (#337)', async () => { + const audit: AuditEntry[] = [ + // Named structurally — resolves directly. + { + kind: 'PenaltyApplied', + at: 1_700_000_000_000_000, + at_ref: 20, + competitor: 'goose-yla6dp', + summary: 'DQ applied' + }, + // Filed protest (the resolution below targets it). + { + kind: 'ProtestFiled', + at: 1_700_000_000_000_001, + at_ref: 21, + competitor: 'maverick-4d9rp8', + summary: 'Protest filed: contact' + }, + // Lap-addressed: no structured competitor; ref 12 is Maverick's lap end pass in FN_LAPS. + { + kind: 'LapThrownOut', + at: 1_700_000_000_000_002, + at_ref: 30, + competitor: null, + summary: 'Lap thrown out (ref 12)' + }, + // Target-addressed: resolves by chasing the filed entry at ref 21 (Maverick). + { + kind: 'ProtestResolved', + at: 1_700_000_000_000_003, + at_ref: 32, + competitor: null, + summary: 'Protest upheld (ref 21)' + } + ]; + const { session } = renderFN(audit); + render(Marshaling, { session }); + + const reverse = screen.getByLabelText('Reverse ruling') as HTMLSelectElement; + await waitFor(() => { + const labels = Array.from(reverse.options).map((o) => o.textContent?.trim()); + expect(labels).toContain('DQ applied — Goose · #20'); + expect(labels).toContain('Lap thrown out — Maverick · #30'); + expect(labels).toContain('Protest upheld — Maverick · #32'); + }); + // No option leaks a raw "(ref N)" or an unresolved pilot-id ref; values stay the offsets. + const labels = Array.from(reverse.options).map((o) => o.textContent ?? ''); + expect(labels.some((l) => l.includes('(ref'))).toBe(false); + expect(labels.some((l) => l.includes('goose-yla6dp') || l.includes('maverick-4d9rp8'))).toBe( + false + ); + const thrownOut = Array.from(reverse.options).find((o) => + o.textContent?.includes('Lap thrown out') + )!; + expect(thrownOut.value).toBe('30'); + + // The resolve-protest picker composes the same way. + const resolve = screen.getByLabelText('Resolve protest') as HTMLSelectElement; + const resolveLabels = Array.from(resolve.options).map((o) => o.textContent?.trim()); + expect(resolveLabels).toContain('Protest filed: contact — Maverick · #21'); + + // The audit lines resolve the same targets: callsign first, the offset only as "· #N". + const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + expect(panel.getByText('Maverick · Lap thrown out · #12')).toBeInTheDocument(); + expect(panel.getByText('Maverick · Protest upheld · #21')).toBeInTheDocument(); + expect(panel.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); + }); + + // ── #340: a failed pilots/heats read must surface, not silently strand raw refs ────────────── + it('surfaces a visible retry state when the pilot/heat directory reads fail (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: EVENT, + live: FN_LIVE, + laps: FN_LAPS, + audit: FN_AUDIT, + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [FN_HEAT]; + }), + listPilotsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return PILOTS as unknown as never; + }), + listChannelsImpl: vi.fn(async () => []) + }); + render(Marshaling, { session }); + + // The failure is visible — no more silently-empty directory + raw refs. + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the pilot\/heat directory/); + + // Retry with the reads healthy again: the error clears and the names resolve. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Maverick' })).toBeInTheDocument() + ); + }); + // ── The actual regression #236 left open: the context-load race ───────────────────────────── // // #236 wired the resolvers but its tests always rendered with the event + its heats/pilots diff --git a/frontend/apps/rd-console/tests/results.test.ts b/frontend/apps/rd-console/tests/results.test.ts index a6823b4..4e56d42 100644 --- a/frontend/apps/rd-console/tests/results.test.ts +++ b/frontend/apps/rd-console/tests/results.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { ClassStanding, CompetitorRef, RankEntry } from '@gridfpv/types'; +import type { ClassStanding, CompetitorRef, HeatResult, RankEntry } from '@gridfpv/types'; import { buildResultsExport, toExportJson } from '../src/lib/results.js'; describe('toExportJson', () => { @@ -60,4 +60,39 @@ describe('buildResultsExport (friendly names, P1-2)', () => { const parsed = JSON.parse(json); expect(parsed.class_standings.standings[0].competitor).toBe('AceOne'); }); + + // #341: the legacy event-level sections (`standings` / `heatResult`) were carried through as-is, + // so their competitor fields leaked raw refs even though every other view resolved. They must + // resolve through the same resolver, raw ref kept alongside. + it('resolves the legacy standings + heat-result sections too (#341)', () => { + const standings: RankEntry[] = [{ competitor: 'p1', position: 1 }]; + const heatResult: HeatResult = { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'p1' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_250_000 }, + best_lap_micros: 41_250_000 + } + ] + }; + const out = buildResultsExport({ resolveCompetitor, standings, heatResult }); + + // The standings rows resolve like every other view (raw ref kept alongside). + expect(out.standings?.[0].competitor).toBe('AceOne'); + expect(out.standings?.[0].competitor_ref).toBe('p1'); + expect(out.standings?.[0].position).toBe(1); + + // The heat result's placements resolve the CompetitorKey's ref; the payload is preserved. + expect(out.heatResult?.places[0].competitor).toBe('AceOne'); + expect(out.heatResult?.places[0].competitor_ref).toBe('p1'); + expect(out.heatResult?.places[0].position).toBe(1); + expect(out.heatResult?.places[0].laps).toBe(3); + + // Nothing in the serialized export shows a bare raw ref as the display field. + const parsed = JSON.parse(toExportJson(out)); + expect(parsed.standings[0].competitor).toBe('AceOne'); + expect(parsed.heatResult.places[0].competitor).toBe('AceOne'); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 5cd6076..3cb8164 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -189,7 +189,11 @@ export function makeTestSession( deleteTimerImpl: opts?.deleteTimerImpl, setEventTimersImpl: opts?.setEventTimersImpl, setPrimaryTimerImpl: opts?.setPrimaryTimerImpl, - listPilotsImpl: opts?.listPilotsImpl, + // The pilots/heats directory reads default to an INERT SUCCESS (empty list), not the real + // fetch-backed impls: `fetch` is stubbed to fail below, and a failed directory read now renders + // a visible error state (#340) — which would leak into every test that doesn't override these + // seams. Resolving `[]` preserves the old default semantics (empty directory, no error). + listPilotsImpl: opts?.listPilotsImpl ?? (async () => []), // Pilot-directory write seams (issue #74): inert unless a test overrides them. createPilotImpl: opts?.createPilotImpl, updatePilotImpl: opts?.updatePilotImpl, @@ -216,8 +220,9 @@ export function makeTestSession( createRoundImpl: opts?.createRoundImpl, updateRoundImpl: opts?.updateRoundImpl, deleteRoundImpl: opts?.deleteRoundImpl, - // Scheduled-heats read seam (race redesign Slice 3b): inert unless a test overrides it. - listHeatsImpl: opts?.listHeatsImpl, + // Scheduled-heats read seam (race redesign Slice 3b): inert success unless a test overrides it + // (see the listPilotsImpl note — a failing default would trip the #340 error state everywhere). + listHeatsImpl: opts?.listHeatsImpl ?? (async () => []), // Ranking + standings read seams (race redesign Slice 5/6a): inert unless overridden. roundRankingImpl: opts?.roundRankingImpl, roundStandingsImpl: opts?.roundStandingsImpl, From 5f4d16b9d276f986eef3a84c09f4b1e4c8ecffca Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 11:29:55 +0000 Subject: [PATCH 316/362] =?UTF-8?q?fix(server):=20batch-2=20backend=20vali?= =?UTF-8?q?dation=20=E2=80=94=20ScheduleHeat=20guards,=20membership=20prun?= =?UTF-8?q?ing,=20DQ=20metrics,=20h2h=20points=20(#335=20#336=20#339=20#34?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-review hardening, each fix with tests: - ScheduleHeat validation (#335): reject a duplicate competitor in the lineup; a round tag must name one of the event's rounds; a class tag must be selected by the event and (when a round is also tagged) eligible for it; on a tagged heat, every lineup ref naming a directory pilot must be a member of the tagged round's eligible classes' membership (mirroring the FillRound field / console eligible-members picker). node-{i} timer seats and sim free-text refs still pass, so practice-style heats keep working. - ScheduleHeat duplicate id (#341, VERIFIED by probe): the fold re-seeds a repeated HeatScheduled back to Scheduled, so re-using an id silently reset a finished/Final heat and orphaned its result. Only genuinely-new ids are accepted now (re-runs go through Discard/Restart), on both the event-aware and bare command paths. - Stale membership on shrink (#336): the roster PUT (and the per-pilot DELETE, same hole) now prune classes_membership slots whose pilot left the roster; the classes PUT drops deselected classes' membership entries — so FillRound can no longer seat removed pilots past the #330 membership guard. Surviving members keep their channels; empty entries are dropped. - DQ standings-metric asymmetry (#339): a disqualified placement contributes NO best-lap and NO win-condition metric (lap count / consecutive window) to the standings row — the ranking already excluded it (#331), so the row no longer surfaces values the position ignored. The pilot's clean heats still count; class standings share the fixed best-lap fold. - head_to_head linear points (#341, VERIFIED): the no-table fallback priced a finish by heat.result.places.len(), so a no-show shrank every present pilot's points in that heat. It now prices by the round's group_size (an explicit points table was and is unaffected). Added the missing odd-field tests pinning the [2,2,1] one-pilot trailing heat and the [4,2] short trailing group under both scorings. - heats.contract.ts now builds a real class/roster/membership/round before scheduling a tagged heat (required by the #335 validation) and pins the new rejections over the wire. cargo test --workspace green; cargo fmt + clippy --all-targets -D warnings clean; frontend npm run contract green (9 files, 90 tests) against a rebuilt Director. Co-Authored-By: Claude Fable 5 --- crates/engine/src/head_to_head.rs | 135 +++++++- crates/server/src/control_handler.rs | 499 ++++++++++++++++++++++++++- crates/server/src/events.rs | 146 ++++++++ crates/server/src/round_engine.rs | 88 +++++ frontend/contract/heats.contract.ts | 85 ++++- 5 files changed, 926 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/head_to_head.rs b/crates/engine/src/head_to_head.rs index 29ad18d..9ec129d 100644 --- a/crates/engine/src/head_to_head.rs +++ b/crates/engine/src/head_to_head.rs @@ -176,7 +176,13 @@ impl Generator for HeadToHead { let mut totals: BTreeMap = self.field.iter().map(|c| (c.clone(), 0)).collect(); for heat in completed { - let heat_size = heat.result.places.len(); + // 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 @@ -498,6 +504,133 @@ mod tests { 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 registry_builds_head_to_head_with_points_table() { let mut registry = FormatRegistry::new(); diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 23a4c86..bafc76d 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -15,7 +15,7 @@ //! | 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`] | none (it creates the heat) | [`Event::HeatScheduled`] | +//! | [`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 [`Pass`](gridfpv_events::Pass) | [`Event::DetectionVoided`] | @@ -322,6 +322,20 @@ pub fn apply_command_in_event( /// 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 @@ -348,6 +362,15 @@ fn apply_schedule_heat( )); }; + // #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() { @@ -391,6 +414,136 @@ fn apply_schedule_heat( } } +/// 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> { + 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 @@ -732,9 +885,11 @@ fn command_to_event(state: &AppState, command: Command) -> Result Result Ok(Event::HeatScheduled { - 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 @@ -1277,6 +1436,86 @@ mod tests { ))); } + /// 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] @@ -2290,6 +2529,242 @@ mod tests { 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 diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index ced2fc2..ecc5bdb 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -1290,6 +1290,11 @@ impl EventRegistry { .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)?; @@ -1310,6 +1315,15 @@ impl EventRegistry { .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)?; @@ -1575,6 +1589,9 @@ impl EventRegistry { .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)?; @@ -2209,6 +2226,22 @@ fn dedup_preserving_order(items: Vec) -> Vec { 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 { @@ -3403,6 +3436,119 @@ mod tests { 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(); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index fa56e68..2c7699d 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -750,6 +750,13 @@ fn round_best_laps(round: &RoundDef, events: &[Event]) -> 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)) @@ -895,6 +902,13 @@ pub fn round_standings( 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()) @@ -2459,6 +2473,80 @@ mod tests { 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 diff --git a/frontend/contract/heats.contract.ts b/frontend/contract/heats.contract.ts index 1321730..176f949 100644 --- a/frontend/contract/heats.contract.ts +++ b/frontend/contract/heats.contract.ts @@ -2,23 +2,32 @@ * Heats listing contract (race redesign Slice 3b): `GET /events/{id}/heats` + the real * `listHeats` client helper. * - * Schedules a **round/class-tagged** heat over the real control path (`Command::ScheduleHeat`), - * then reads it back through the real `@gridfpv/protocol-client`'s `listHeats`. Asserts the served - * `HeatSummary` round-trips the tag (round + class), the lineup, and a derived status — the exact - * shape the Heats UI groups by round. If the new endpoint, the binding, or the tag plumbing were - * wrong, the heat would not come back tagged. + * Sets up a real class + roster + membership + round on the Practice event (ScheduleHeat now + * validates its round/class tag + membership against the event meta, #335), schedules a + * **round/class-tagged** heat over the real control path (`Command::ScheduleHeat`), then reads it + * back through the real `@gridfpv/protocol-client`'s `listHeats`. Asserts the served `HeatSummary` + * round-trips the tag (round + class), the lineup, and a derived status — the exact shape the + * Heats UI groups by round. If the new endpoint, the binding, or the tag plumbing were wrong, the + * heat would not come back tagged. Also pins the #335 rejections: a dangling round tag and a + * non-member pilot on a tagged heat are failed acks (nothing scheduled). */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { listHeats, PRACTICE_EVENT_ID } from '../packages/protocol-client/dist/index.js'; +import { + createClass, + createPilot, + createRound, + listHeats, + PRACTICE_EVENT_ID, + setClassMembership, + setEventClasses, + setEventRoster +} from '../packages/protocol-client/dist/index.js'; import { type Director } from '../test-harness/director.ts'; import { rdControl, startContractDirector } from './harness.ts'; const TOKEN = 'rd-heats-contract'; const HEAT = 'q-1'; -const LINEUP = ['A', 'B']; -const ROUND = 'r1'; -const CLASS = 'open'; const LABEL = 'Featured Heat'; let director: Director; @@ -33,9 +42,40 @@ afterAll(async () => { describe('GET /heats serves the round-tagged scheduled heats', () => { it('listHeats returns a tagged HeatSummary with lineup and a derived status', async () => { - // Schedule a heat tagged with a round + class + a custom label over the real control path. + // Real directory setup: a class + two pilots, selected + rostered + membered on Practice — + // a tagged ScheduleHeat is validated against exactly this meta (#335). + const klass = await createClass(director.baseUrl, { name: 'Open' }, TOKEN); + const pilotA = await createPilot(director.baseUrl, { callsign: 'alpha' }, TOKEN); + const pilotB = await createPilot(director.baseUrl, { callsign: 'bravo' }, TOKEN); + await setEventClasses(director.baseUrl, PRACTICE_EVENT_ID, [klass.id], TOKEN); + await setEventRoster(director.baseUrl, PRACTICE_EVENT_ID, [pilotA.id, pilotB.id], TOKEN); + await setClassMembership( + director.baseUrl, + PRACTICE_EVENT_ID, + klass.id, + [pilotA.id, pilotB.id], + TOKEN + ); + const round = await createRound( + director.baseUrl, + PRACTICE_EVENT_ID, + { + label: 'Qualifying', + classes: [klass.id], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + // Best Lap only ranks, so a scored round needs a race time to end (server validation). + time_limit_secs: 60, + seeding: 'FromRoster' + }, + TOKEN + ); + const lineup = [pilotA.id, pilotB.id]; + + // Schedule a heat tagged with the round + class + a custom label over the real control path. const ack = await rdControl(director.baseUrl, TOKEN, { - ScheduleHeat: { heat: HEAT, lineup: LINEUP, class: CLASS, round: ROUND, label: LABEL } + ScheduleHeat: { heat: HEAT, lineup, class: klass.id, round: round.id, label: LABEL } }); expect(ack.ok).toBe(true); @@ -43,13 +83,30 @@ describe('GET /heats serves the round-tagged scheduled heats', () => { const heats = await listHeats(director.baseUrl, PRACTICE_EVENT_ID); const summary = heats.find((h) => h.heat === HEAT); expect(summary).toBeDefined(); - expect(summary!.lineup).toEqual(LINEUP); - expect(summary!.round).toBe(ROUND); - expect(summary!.class).toBe(CLASS); + expect(summary!.lineup).toEqual(lineup); + expect(summary!.round).toBe(round.id); + expect(summary!.class).toBe(klass.id); // The custom build-heat label round-trips on the wire (overrides the derived name in the UI). expect(summary!.label).toBe(LABEL); // Freshly scheduled and on the timer: Scheduled phase, marked current. expect(summary!.phase).toBe('Scheduled'); expect(summary!.is_current).toBe(true); + + // #335 rejections over the wire: a dangling round tag is a failed ack … + const dangling = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'q-bad-round', lineup, round: 'no-such-round' } + }); + expect(dangling.ok).toBe(false); + + // … and a directory pilot who is NOT a member of the round's classes is rejected too. + const outsider = await createPilot(director.baseUrl, { callsign: 'outsider' }, TOKEN); + const nonMember = await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { heat: 'q-bad-member', lineup: [outsider.id], round: round.id } + }); + expect(nonMember.ok).toBe(false); + + // Neither rejected command scheduled anything. + const after = await listHeats(director.baseUrl, PRACTICE_EVENT_ID); + expect(after.some((h) => h.heat === 'q-bad-round' || h.heat === 'q-bad-member')).toBe(false); }); }); From 95f25139595f5cbfa29782c0ef3fbb7f13c4717a Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 14:03:06 +0000 Subject: [PATCH 317/362] feat(console): GridFPV brand home link on the directory pages The Pilots / Classes / Events / Timers pages now carry the same top-left GridFPV brand button as the event workspace's sidebar, leaving for the home hub. The brand markup/styles moved verbatim into a shared Brand.svelte (App.svelte's sidebar mounts it too; the narrow-viewport mark-only collapse moved with it). Co-Authored-By: Claude Fable 5 --- frontend/apps/rd-console/src/App.svelte | 73 +------------- frontend/apps/rd-console/src/Brand.svelte | 97 +++++++++++++++++++ .../rd-console/src/screens/ClassesPage.svelte | 8 ++ .../rd-console/src/screens/EventPicker.svelte | 8 ++ .../rd-console/src/screens/PilotsPage.svelte | 8 ++ .../rd-console/src/screens/TimersPage.svelte | 8 ++ .../apps/rd-console/tests/EventPicker.test.ts | 16 +++ 7 files changed, 149 insertions(+), 69 deletions(-) create mode 100644 frontend/apps/rd-console/src/Brand.svelte diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 64c08ac..4528d67 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -28,6 +28,7 @@ import { Session } from './lib/session.svelte.js'; import ContextHeader from './ContextHeader.svelte'; import Breadcrumbs from './Breadcrumbs.svelte'; + import Brand from './Brand.svelte'; import HomeHub from './screens/HomeHub.svelte'; import EventPicker from './screens/EventPicker.svelte'; import TimersPage from './screens/TimersPage.svelte'; @@ -266,23 +267,9 @@
    @@ -1557,6 +1517,17 @@ font-size: var(--gf-font-size-2xs); font-family: var(--gf-font-mono); } + /* The jump to the event-wide Audit page (pre-filtered to the marshaled heat). */ + .view-audit { + margin-top: var(--gf-space-3); + width: 100%; + border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); + } + .view-audit:hover:not(:disabled) { + border-color: var(--gf-accent); + color: var(--gf-accent); + background: var(--gf-elevated); + } @media (max-width: 70rem) { .layout { grid-template-columns: 1fr; diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index 8e693dd..0946e4a 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -37,16 +37,24 @@ import { isTimedQualFormat } from '../lib/formats.js'; import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { channelLabel, nodeIndexOf } from '../lib/channels.js'; + import type { AuditPrefilter } from '../lib/auditFilter.svelte.js'; import type { Session } from '../lib/session.svelte.js'; let { session, heatResult = undefined, - standings = undefined + standings = undefined, + onviewaudit = undefined }: { session?: Session; heatResult?: HeatResult; standings?: RankEntry[]; + /** + * Jump to the event-wide Audit page pre-filtered to a pilot (each ROUND-standings row's + * "audit" affordance — the defensible-results answer to "why is this pilot placed here?"). + * The shell wires this to the auditFilter seam (`openAudit(setTab, prefilter)`). + */ + onviewaudit?: (prefilter: AuditPrefilter) => void; } = $props(); const hasEventProjection = $derived(!!heatResult || !!(standings && standings.length)); @@ -437,6 +445,22 @@
    {/snippet} +{#snippet auditLink(competitor: CompetitorRef)} + + {#if onviewaudit} + + {/if} +{/snippet} + {#snippet rankTable(caption: string, rows: RankEntry[])} @@ -449,7 +473,7 @@ {#each rows as row (row.competitor)} - + {/each} @@ -472,7 +496,7 @@ {#each rows as row (row.competitor)} - + {#if ttMetricHeader} @@ -554,6 +578,30 @@ font-weight: var(--gf-font-weight-semibold); letter-spacing: var(--gf-tracking-tight); } + /* The per-pilot Audit-page jump: a quiet pill after the callsign (round views). */ + .audit-link { + margin-left: var(--gf-space-2); + padding: 0.05em 0.55em; + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-pill); + background: transparent; + color: var(--gf-text-muted); + font-family: inherit; + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + cursor: pointer; + vertical-align: middle; + } + .audit-link:hover { + border-color: var(--gf-accent); + color: var(--gf-accent); + } + .audit-link:focus-visible { + outline: none; + box-shadow: var(--gf-focus-ring); + } .standings .pos { width: 2.75em; } diff --git a/frontend/apps/rd-console/tests/EventAudit.test.ts b/frontend/apps/rd-console/tests/EventAudit.test.ts new file mode 100644 index 0000000..cde3d76 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventAudit.test.ts @@ -0,0 +1,248 @@ +/** + * The event-wide Audit page: renders the Director's heat-tagged, newest-first audit trail + * (`GET /events/{id}/audit`) with friendly names everywhere, client-side filters (pilot / heat / + * kind / free text), clickable pilot+heat chips that set those filters, the #340 error+retry + * treatment, and the cross-screen prefilter seam (auditFilter) consumed on mount. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import type { EventAuditEntry, EventMeta, HeatSummary, RoundDef } from '@gridfpv/types'; +import EventAudit from '../src/screens/EventAudit.svelte'; +import { consumeAuditPrefilter, openAudit } from '../src/lib/auditFilter.svelte.js'; +import { makeTestSession } from './support.js'; + +// A roster-seeded event: the competitor refs ARE directory pilot ids, so callsigns must resolve +// from the pilots directory alone (the common FromRoster case), never render raw. +const ROUND = { + id: 'r1', + label: 'Qualifying R1', + classes: ['c1'], + format: 'timed_qual', + params: {}, + win_condition: { Timed: { window_micros: 120_000_000 } }, + seeding: 'FromRoster', + channel_mode: 'Static', + protest_window: 'Off' +} as unknown as RoundDef; +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + roster: [], + classes: ['c1'], + rounds: [ROUND] +}; +const PILOTS = [ + { id: 'maverick-4d9rp8', callsign: 'Maverick', vtx_types: [] }, + { id: 'goose-yla6dp', callsign: 'Goose', vtx_types: [] } +]; +const HEATS: HeatSummary[] = [ + { + heat: 'q1-heat', + lineup: ['maverick-4d9rp8', 'goose-yla6dp'], + round: 'r1', + class: 'c1', + frequencies: [], + phase: 'Final', + is_current: false + }, + { + heat: 'q2-heat', + lineup: ['maverick-4d9rp8', 'goose-yla6dp'], + round: 'r1', + class: 'c1', + frequencies: [], + phase: 'Unofficial', + is_current: true + } +]; + +// The server's trail: newest first (descending at_ref), heat-tagged. The reversal (#40) targets +// the DQ (#20) so its pilot resolves by CHASING the chain; the void's target (a pass offset) is +// not an audit entry and the page holds no lap list, so that row renders with no pilot chip. +const ENTRIES: EventAuditEntry[] = [ + { + heat: 'q1-heat', + kind: 'RulingReversed', + at: 1_700_000_400_000_000, + at_ref: 40, + competitor: null, + summary: 'Ruling reversed (ref 20)' + }, + { + heat: 'q2-heat', + kind: 'Voided', + at: 1_700_000_300_000_000, + at_ref: 30, + competitor: null, + summary: 'Detection voided (ref 25)' + }, + { + heat: 'q1-heat', + kind: 'PenaltyApplied', + at: 1_700_000_200_000_000, + at_ref: 20, + competitor: 'goose-yla6dp', + summary: 'DQ applied' + }, + { + heat: 'q1-heat', + kind: 'ProtestFiled', + at: 1_700_000_100_000_000, + at_ref: 10, + competitor: 'maverick-4d9rp8', + summary: 'Protest filed: contact' + } +]; + +function renderAudit(overrides: Parameters[0] = {}) { + const made = makeTestSession({ + event: EVENT, + eventAuditImpl: vi.fn(async () => ENTRIES), + listHeatsImpl: vi.fn(async () => HEATS), + listPilotsImpl: vi.fn(async () => PILOTS as unknown as never), + listChannelsImpl: vi.fn(async () => []), + ...overrides + }); + render(EventAudit, { session: made.session }); + return made; +} + +const rows = () => within(screen.getByLabelText('Audit entries')).getAllByRole('listitem'); + +describe('EventAudit — the event-wide audit page', () => { + it('renders the trail newest-first with kind badges, callsigns, and friendly heat names', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + const entries = rows(); + + // Newest first, as served (descending at_ref) — the page must not re-order. + expect(entries[0]).toHaveTextContent('Ruling reversed'); + expect(entries[3]).toHaveTextContent('Protest filed'); + + // The PenaltyApplied entry's badge derives the finer 'DQ' from the summary. + expect(within(entries[2]).getByText('DQ')).toBeInTheDocument(); + + // Pilot resolution: structured ref (Goose), and CHASED through the reversal's target (#20 → + // the DQ → Goose). The heat renders as its friendly " Heat N" name. + await waitFor(() => { + expect(within(entries[0]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + expect(within(entries[2]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + expect( + within(entries[0]).getByRole('button', { name: 'Qualifying R1 Heat 1' }) + ).toBeInTheDocument(); + expect( + within(entries[1]).getByRole('button', { name: 'Qualifying R1 Heat 2' }) + ).toBeInTheDocument(); + }); + + // The trailing detail is the entry's own log ref; the raw "(ref N)" clause is stripped from + // the text (the target renders only as the "· #N" tail, #337). + expect(within(entries[0]).getByText('#40')).toBeInTheDocument(); + expect(entries[0]).toHaveTextContent('Ruling reversed · #20'); + expect(screen.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); + + // No raw ids anywhere in the rendered rows. + expect(screen.queryByText(/goose-yla6dp/)).not.toBeInTheDocument(); + expect(screen.queryByText(/maverick-4d9rp8/)).not.toBeInTheDocument(); + expect(screen.queryByText(/q1-heat/)).not.toBeInTheDocument(); + }); + + it('clicking a pilot chip filters to that pilot; a heat chip filters to that heat', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + + // Click Goose's chip on the DQ row → only the rows resolving to Goose remain (the DQ and + // the reversal that chases to it), and the pilot select reflects the filter. + await fireEvent.click(within(rows()[2]).getByRole('button', { name: 'Goose' })); + await waitFor(() => expect(rows()).toHaveLength(2)); + expect((screen.getByLabelText('Filter by pilot') as HTMLSelectElement).value).toBe( + 'goose-yla6dp' + ); + + // Reset, then click the q2 heat chip → only q2's row remains. + await fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + await waitFor(() => expect(rows()).toHaveLength(4)); + await fireEvent.click(within(rows()[1]).getByRole('button', { name: 'Qualifying R1 Heat 2' })); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('Detection voided'); + }); + + it('filters by kind and by free text over the rendered row', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + + // The kind select offers exactly the badges present. + const kind = screen.getByLabelText('Filter by kind') as HTMLSelectElement; + const offered = Array.from(kind.options).map((o) => o.textContent?.trim()); + expect(offered).toEqual([ + 'All kinds', + 'Ruling reversed', + 'Detection voided', + 'DQ', + 'Protest filed' + ]); + await fireEvent.change(kind, { target: { value: 'DQ' } }); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('DQ applied'); + + await fireEvent.change(kind, { target: { value: '' } }); + // Free text matches the RENDERED text — the resolved callsign, not the raw ref. + await fireEvent.input(screen.getByLabelText('Search audit entries'), { + target: { value: 'maverick' } + }); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('Protest filed: contact'); + // A raw ref must NOT match (nothing renders it). + await fireEvent.input(screen.getByLabelText('Search audit entries'), { + target: { value: 'maverick-4d9rp8' } + }); + await waitFor(() => + expect(screen.getByText('No entries match the current filters.')).toBeInTheDocument() + ); + }); + + it('consumes (and clears) the cross-screen prefilter on mount', async () => { + // Another screen deposited a heat prefilter and switched tabs (the auditFilter seam). + const setTab = vi.fn(); + openAudit(setTab, { heat: 'q2-heat' }); + expect(setTab).toHaveBeenCalledWith('audit'); + + renderAudit(); + // The heat filter arrives pre-set → only q2's entry shows. + await waitFor(() => expect(rows()).toHaveLength(1)); + expect((screen.getByLabelText('Filter by heat') as HTMLSelectElement).value).toBe('q2-heat'); + // Consumed: nothing left in the mailbox for a later visit. + expect(consumeAuditPrefilter()).toBeUndefined(); + }); + + it('surfaces a visible error with retry when the audit read fails (#340 — no silent [])', async () => { + let fail = true; + renderAudit({ + eventAuditImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return ENTRIES; + }) + }); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the audit trail/); + + // Retry with the read healthy: the error clears and the trail renders. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + await waitFor(() => expect(rows()).toHaveLength(4)); + }); + + it('is a pure read — renders identically for a read-only session (no gated controls)', async () => { + renderAudit({ role: 'readonly' }); + await waitFor(() => expect(rows()).toHaveLength(4)); + // The chips + filters are navigation/filtering, not mutations — all present read-only. + expect(screen.getByLabelText('Filter by pilot')).toBeInTheDocument(); + expect(within(rows()[2]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index bfff7d6..c0593d1 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -374,25 +374,56 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ Revert: { heat: 'heat-1' } }); }); - it('renders the audit trail newest-first', () => { + // ── The Recent-rulings strip (the full trail moved to the event-wide Audit page) ───────────── + it('the Recent-rulings strip renders the latest entries newest-first (composed lines)', () => { const { session } = makeTestSession({ live: liveRunning, laps: lapList, audit: marshalingAudit }); render(Marshaling, { session }); - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); const entries = panel.getAllByRole('listitem'); // Newest first: the DQ (at_ref 20) precedes the void (at_ref 18). The competitor name is composed // from the STRUCTURED ref (resolved to its callsign — here the bare ref, no directory seeded). expect(entries[0]).toHaveTextContent('CARMEN · DQ applied'); // The void names no competitor structurally, but its target (ref 12) is ALICE's lap-1 end pass // in the lap list — the line resolves her name and renders the raw "(ref 12)" only as the - // trailing "· #12" detail (#337). + // trailing "· #12" detail (#337 — the SHARED auditRender helpers). expect(entries[1]).toHaveTextContent('ALICE · Detection voided · #12'); expect(entries[1]).not.toHaveTextContent('(ref 12)'); }); + it('the strip shows at most the latest 3 entries — the full history lives on the Audit page', () => { + // Four rulings, newest first (as the server serves them): only the first three render. + const audit: AuditEntry[] = [40, 30, 20, 10].map((at_ref) => ({ + kind: 'PenaltyApplied' as const, + at: 1_700_000_000_000_000 + at_ref, + at_ref, + competitor: 'BOB', + summary: `DQ applied (strike ${at_ref})` + })); + const { session } = makeTestSession({ live: liveRunning, laps: lapList, audit }); + render(Marshaling, { session }); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); + expect(panel.getAllByRole('listitem')).toHaveLength(3); + expect(panel.getByText(/strike 40/)).toBeInTheDocument(); + expect(panel.queryByText(/strike 10/)).toBeNull(); + }); + + it('"View full audit →" jumps to the Audit tab pre-filtered to the MARSHALED heat', async () => { + const onviewaudit = vi.fn(); + const { session } = makeTestSession({ + live: liveRunning, + laps: lapList, + audit: marshalingAudit + }); + render(Marshaling, { session, onviewaudit }); + await fireEvent.click(screen.getByRole('button', { name: 'View full audit →' })); + // The seam receives the marshaled heat as the prefilter (the shell deposits it + switches tab). + expect(onviewaudit).toHaveBeenCalledWith({ heat: 'heat-1' }); + }); + // ── Slice 4: the signal-as-evidence RSSI graph ──────────────────────────────────────── describe('signal-as-evidence graph (Slice 4)', () => { it('renders the graph with threshold lines + a lap marker per lap when a trace is present', () => { @@ -731,10 +762,10 @@ describe('Marshaling (Slice 3)', () => { expect(goose.value).toBe('goose-yla6dp'); }); - it('composes the audit line with the RESOLVED callsign from the structured ref', async () => { + it('composes the strip line with the RESOLVED callsign from the structured ref', async () => { const { session } = renderFN(); render(Marshaling, { session }); - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); // The DQ line shows the callsign, never the raw pilot id. await waitFor(() => expect(panel.getByText('Goose · DQ applied')).toBeInTheDocument()); expect(panel.getByText('Maverick · Protest filed: cut the course')).toBeInTheDocument(); @@ -809,10 +840,11 @@ describe('Marshaling (Slice 3)', () => { const resolveLabels = Array.from(resolve.options).map((o) => o.textContent?.trim()); expect(resolveLabels).toContain('Protest filed: contact — Maverick · #21'); - // The audit lines resolve the same targets: callsign first, the offset only as "· #N". - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + // The strip's lines resolve the same targets (SHARED helpers): callsign first, the offset + // only as "· #N", never a server-baked "(ref N)". The lap-addressed throw-out (3rd-newest, + // within the latest-3 strip) resolves Maverick through the lap list. + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); expect(panel.getByText('Maverick · Lap thrown out · #12')).toBeInTheDocument(); - expect(panel.getByText('Maverick · Protest upheld · #21')).toBeInTheDocument(); expect(panel.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); }); @@ -950,8 +982,8 @@ describe('Marshaling (Slice 3)', () => { expect(opts).toContain('Maverick'); const mav = Array.from(ruling.options).find((o) => o.textContent?.trim() === 'Maverick')!; expect(mav.value).toBe('node-0'); - // The audit line composes the resolved callsign from the structured ref. - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + // The strip line composes the resolved callsign from the structured ref. + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); expect(panel.getByText('Maverick · DQ applied')).toBeInTheDocument(); // The raw "node-0" must appear NOWHERE the resolver renders a name. expect(screen.queryByRole('heading', { name: 'node-0' })).not.toBeInTheDocument(); diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts index 3ac7c58..29a54aa 100644 --- a/frontend/apps/rd-console/tests/ResultsScreen.test.ts +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -241,6 +241,26 @@ describe('Results — phase-aware views (round / per-class selector)', () => { expect(within(table).getByText('Bolt')).toBeInTheDocument(); expect(within(table).getByText('AceOne')).toBeInTheDocument(); }); + + it('each ROUND-standings row offers an audit jump pre-filtered to that pilot', async () => { + // The per-pilot "audit" affordance (the defensible-results cross-link): clicking it hands the + // pilot's competitor ref to the auditFilter seam, which the shell wires to the Audit tab. + const onviewaudit = vi.fn(); + const { session } = makeTestSession({ + event: { ...EVENT, rounds: [QUAL] }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl: vi.fn(async () => [QUAL_HEAT]), + roundRankingImpl: rankingImpl, + classStandingsImpl: vi.fn(async () => STANDINGS) + }); + render(Results, { session, onviewaudit }); + + const table = (await screen.findByLabelText(/Qualifying standings/i)) as HTMLElement; + // The affordance is labelled by the resolved callsign — never the raw pilot id. + await fireEvent.click(within(table).getByRole('button', { name: 'View audit for Bolt' })); + expect(onviewaudit).toHaveBeenCalledWith({ pilot: 'p2' }); + }); }); describe('Results — time-trial round standings (Best lap + win-condition metric)', () => { diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 3cb8164..a33cf13 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -38,6 +38,7 @@ import type { updateRound, deleteRound, listHeats, + eventAudit, roundRanking, roundStandings, classStandings @@ -112,6 +113,8 @@ export interface TimerImpls { deleteRoundImpl?: typeof deleteRound; /** The scheduled-heats read (race redesign Slice 3b) — backs the EventRounds Heats UI tests. */ listHeatsImpl?: typeof listHeats; + /** The event-wide audit read — backs the EventAudit page tests. */ + eventAuditImpl?: typeof eventAudit; /** The round-ranking + class-standings reads (race redesign Slice 5/6a) — back the Results + * EventRounds standings/advance tests. */ roundRankingImpl?: typeof roundRanking; @@ -223,6 +226,9 @@ export function makeTestSession( // Scheduled-heats read seam (race redesign Slice 3b): inert success unless a test overrides it // (see the listPilotsImpl note — a failing default would trip the #340 error state everywhere). listHeatsImpl: opts?.listHeatsImpl ?? (async () => []), + // The event-wide audit read: inert success (empty trail) unless a test overrides it — a + // failing default would trip the Audit page's #340 error state in unrelated tests. + eventAuditImpl: opts?.eventAuditImpl ?? (async () => []), // Ranking + standings read seams (race redesign Slice 5/6a): inert unless overridden. roundRankingImpl: opts?.roundRankingImpl, roundStandingsImpl: opts?.roundStandingsImpl, diff --git a/frontend/contract/audit.contract.ts b/frontend/contract/audit.contract.ts new file mode 100644 index 0000000..1429fba --- /dev/null +++ b/frontend/contract/audit.contract.ts @@ -0,0 +1,159 @@ +/** + * Event-wide audit contract: `GET /events/{id}/audit` plus the real `eventAudit` client helper. + * + * Drives a real Director end-to-end: directory setup (class + pilots + round), schedule + run + + * finalize a heat, then two marshaling rulings — a penalty (heat-tagged) and a detection void + * (target-addressed, aimed at a real pass offset read back from the laps projection). The + * event-wide audit must then serve both rulings through the real `@gridfpv/protocol-client`, + * each **tagged with the heat** it rules on and in **newest-first** order (descending global + * append offset) — the same window-attributed entries Marshaling's per-heat `?projection=audit` + * serves, so the Audit page and Marshaling can never disagree. If the route, the flattened + * `EventAuditEntry` binding, or the merge/sort were wrong, the shapes would not round-trip. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + createClass, + createPilot, + createRound, + eventAudit, + PRACTICE_EVENT_ID, + setClassMembership, + setEventClasses, + setEventRoster +} from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { driveToRunning, eventRoot, rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-audit-contract'; +const HEAT = 'a-1'; + +let director: Director; + +beforeAll(async () => { + // A brisk sim (one quick lap each) so the heat closes fast and the rulings can land. + director = await startContractDirector({ token: TOKEN, simLaps: 1, simLapMs: 30 }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('GET /events/{id}/audit serves the heat-tagged, newest-first event audit', () => { + it('rulings on a finalized heat arrive heat-tagged, newest first', async () => { + // Real directory setup: a class + two pilots, selected + rostered + membered on Practice. + const klass = await createClass(director.baseUrl, { name: 'Open' }, TOKEN); + const pilotA = await createPilot(director.baseUrl, { callsign: 'alpha' }, TOKEN); + const pilotB = await createPilot(director.baseUrl, { callsign: 'bravo' }, TOKEN); + await setEventClasses(director.baseUrl, PRACTICE_EVENT_ID, [klass.id], TOKEN); + await setEventRoster(director.baseUrl, PRACTICE_EVENT_ID, [pilotA.id, pilotB.id], TOKEN); + await setClassMembership( + director.baseUrl, + PRACTICE_EVENT_ID, + klass.id, + [pilotA.id, pilotB.id], + TOKEN + ); + const round = await createRound( + director.baseUrl, + PRACTICE_EVENT_ID, + { + label: 'Qualifying', + classes: [klass.id], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + time_limit_secs: 60, + seeding: 'FromRoster' + }, + TOKEN + ); + + // An empty event has an empty audit (a 200 [], not an error). + expect(await eventAudit(director.baseUrl, PRACTICE_EVENT_ID)).toEqual([]); + + // Schedule + run + finalize the round's heat. + expect( + ( + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { + heat: HEAT, + lineup: [pilotA.id, pilotB.id], + class: klass.id, + round: round.id + } + }) + ).ok + ).toBe(true); + await driveToRunning(director.baseUrl, TOKEN, HEAT); + await waitForBothLaps(director.baseUrl, HEAT); + expect((await rdControl(director.baseUrl, TOKEN, { ForceEnd: { heat: HEAT } })).ok).toBe(true); + expect((await rdControl(director.baseUrl, TOKEN, { Finalize: { heat: HEAT } })).ok).toBe(true); + + // Two rulings on the finalized heat: a heat-tagged penalty, then a target-addressed void + // aimed at a REAL pass offset (pilot A's first lap's end pass, read from the laps projection). + expect( + ( + await rdControl(director.baseUrl, TOKEN, { + ApplyPenalty: { + heat: HEAT, + competitor: pilotB.id, + penalty: { Disqualify: { reason: 'contract: flew the wrong course' } } + } + }) + ).ok + ).toBe(true); + const voidTarget = await firstLapEndRef(director.baseUrl, HEAT); + expect( + (await rdControl(director.baseUrl, TOKEN, { VoidDetection: { target: voidTarget } })).ok + ).toBe(true); + + // The event-wide audit, through the real client helper. + const trail = await eventAudit(director.baseUrl, PRACTICE_EVENT_ID); + expect(trail).toHaveLength(2); + + // Newest first: descending global append offset — the void (appended last) leads. + expect(trail[0].at_ref).toBeGreaterThan(trail[1].at_ref); + expect(trail[0].kind).toBe('Voided'); + expect(trail[1].kind).toBe('PenaltyApplied'); + + // Every entry carries the heat it rules on (the flattened EventAuditEntry shape: the per-heat + // AuditEntry fields plus `heat`), with the structured competitor on the penalty. + for (const entry of trail) expect(entry.heat).toBe(HEAT); + expect(trail[1].competitor).toBe(pilotB.id); + expect(trail[1].summary).toContain('DQ applied'); + expect(trail[0].summary).toContain(`(ref ${voidTarget})`); + }); +}); + +/** Poll the heat's `laps` projection until every competitor present has at least one completed lap. */ +async function waitForBothLaps(baseUrl: string, heat: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const competitors = await lapCompetitors(baseUrl, heat); + if (competitors.length >= 2 && competitors.every((c) => c.laps.length >= 1)) return; + await new Promise((r) => setTimeout(r, 30)); + } + throw new Error(`both pilots did not bank a lap within ${timeoutMs}ms`); +} + +/** The heat's first competitor's first-lap end pass offset — a real `LogRef` a void can target. */ +async function firstLapEndRef(baseUrl: string, heat: string): Promise { + const competitors = await lapCompetitors(baseUrl, heat); + const endRef = competitors[0]?.laps[0]?.end_ref; + if (typeof endRef !== 'number') throw new Error('no lap end_ref to void'); + return endRef; +} + +/** The heat's `?projection=laps` competitor list (empty on a non-2xx read). */ +async function lapCompetitors( + baseUrl: string, + heat: string +): Promise }>> { + const resp = await fetch(`${eventRoot(baseUrl)}/snapshot/heat/${heat}?projection=laps`); + if (!resp.ok) return []; + const snap = (await resp.json()) as { + body: { LapList?: { competitors: Array<{ laps: Array<{ end_ref: number }> }> } }; + }; + return snap.body.LapList?.competitors ?? []; +} diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 74a7b4d..3453aa9 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -29,6 +29,7 @@ import type { CreatePilotRequest, CreateTimerRequest, Cursor, + EventAuditEntry, EventId, EventMeta, FormatSchema, @@ -1028,6 +1029,26 @@ export async function listHeats( return (await resp.json()) as HeatSummary[]; } +/** + * Read an event's **event-wide audit trail** (`GET /events/{id}/audit`) — the "defensible + * results" review surface. A read (open, no token): every heat's marshaling audit fold, + * heat-tagged ({@link EventAuditEntry} = the per-heat `AuditEntry` fields plus `heat`) and merged + * **newest first** across the whole event — what the console's Audit page renders and filters. + * Resolves the list, or rejects on a non-2xx / transport failure; an unknown event is a 404. + */ +export async function eventAudit( + baseUrl: string, + eventId: EventId, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}${eventRoot(eventId)}/audit`, { headers }); + if (!resp.ok) throw new Error(`GET /events/${eventId}/audit failed: HTTP ${resp.status}`); + return (await resp.json()) as EventAuditEntry[]; +} + /** * Read a round's **ranking** (`GET /events/{id}/rounds/{round}/ranking`) — race redesign Slice 5/6a. * A read (open, no token): the ordered per-pilot {@link RankEntry} list the engine seeds diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 9a60471..b24ab95 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -46,6 +46,7 @@ export { updateRound, deleteRound, listHeats, + eventAudit, roundRanking, roundStandings, classStandings, diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index d682221..9c2c82c 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -55,6 +55,7 @@ export type * from '@bindings/CreateTimerRequest'; export type * from '@bindings/Cursor'; export type * from '@bindings/ErrorCode'; export type * from '@bindings/Event'; +export type * from '@bindings/EventAuditEntry'; export type * from '@bindings/EventId'; export type * from '@bindings/EventMeta'; export type * from '@bindings/EventOutcome'; From 5df126e272e14377b2838f87fc746e85ec71b461 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 20:24:13 +0000 Subject: [PATCH 325/362] =?UTF-8?q?fix(marshaling):=20no=20rulings=20befor?= =?UTF-8?q?e=20commit=20=E2=80=94=20kill=20click-to-insert;=20unified=20re?= =?UTF-8?q?-detection=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-testing surfaced rulings appearing BEFORE any explicit commit while tuning detection thresholds, plus baffling "Lap inserted" audit entries while removing laps. Root cause: the RSSI trace svg carried an onclick that IMMEDIATELY sent an InsertLap at the cursor time — and the browser synthesizes a click on the svg when a threshold drag (pointerdown → pointerup on a handle) finishes inside it, so EVERY drag planted a phantom lap; stray clicks did the same. Fix 1 — no direct click-to-insert, ever (RssiGraph.svelte): - The svg's click-to-insert path is removed entirely. The ONLY add path is the explicit, labelled "Add lap here" button in the cursor readout under the plot (deliberate, un-misfireable). - The in-plot hover hint now points at that button ("add lap ↓ below"); the crosshair cursor cues the position readout, not a click action. - Lap-marker clicks (select-lap) keep working unchanged. - Tests: a trace click sends NOTHING (component + screen level); a full threshold drag followed by the browser-synthesized svg click sends NOTHING; the readout button still sends the exact heat-tagged InsertLap. Fix 2 — unified re-detection preview (redetect.ts + Marshaling.svelte): - New pure `previewRows(current, detected, tolerance?)`: ONE chronological list telling the whole story — laps derived from the surviving pass chain, each `kept` (closing pass matched an official one) or `added` (new), interleaved with `removed` rows for every official pass the re-detection drops (passes leaving the record — no lap number). Unit-tested: kept/added classification, removed interleaving in time order, pure-kept diffs, tolerance matching. - The tune panel's side-by-side preview-vs-official lap lists are replaced by that single list: kept rows plain, added rows accent-marked "+", removed rows struck/dimmed "− pass at Ts — removed". The "Would be N laps (+A, −R)" summary stays; the exact commit command batch (VoidDetection/InsertLap) is untouched — render-only. Gates: rd-console svelte-check (both passes) clean; vitest 42 files / 544 tests green; frontend eslint + prettier clean. Co-Authored-By: Claude Fable 5 --- .../apps/rd-console/src/lib/RssiGraph.svelte | 54 +++++------- frontend/apps/rd-console/src/lib/redetect.ts | 52 ++++++++++++ .../rd-console/src/screens/Marshaling.svelte | 82 +++++++++++++----- .../rd-console/tests/MarshalingScreen.test.ts | 85 +++++++++++++++++-- .../apps/rd-console/tests/RssiGraph.test.ts | 58 +++++++++---- .../apps/rd-console/tests/redetect.test.ts | 58 ++++++++++++- 6 files changed, 313 insertions(+), 76 deletions(-) diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index 18f5f44..8228cd5 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -56,14 +56,16 @@ onselect: (competitor: CompetitorRef, lap: Lap) => void; /** * Add a brand-new lap for a competitor at a source-clock time (the cursor's race-relative - * instant). Wired to a click on the trace / the "Add lap here" affordance at the crosshair. + * instant). Wired ONLY to the explicit, labelled "Add lap here" button in the cursor readout + * below the plot — never to a bare click on the trace: stray clicks (and the click the browser + * synthesizes when a threshold drag ends inside the svg) must not plant phantom laps. * Optional: when absent (or when `canControl` is false) the graph is review-only. */ onaddlap?: (competitor: CompetitorRef, at: number) => void; /** * Whether the session may mutate (the role gate — read-only pilots can't add laps). When false - * the add-lap affordance is hidden and a trace click does nothing, mirroring the parent's - * `canControl` boundary on every other correction. + * the "Add lap here" affordance is hidden, mirroring the parent's `canControl` boundary on + * every other correction. */ canControl?: boolean; /** @@ -316,18 +318,11 @@ hover = null; } - /** Click on the trace → add a lap at the cursor's race-relative time (role-gated). */ - function onTraceClick( - e: MouseEvent, - ct: CompetitorTrace, - span: { from: number; to: number } - ): void { - if (!canControl || !onaddlap) return; - const svg = e.currentTarget as SVGSVGElement; - const px = pointerX(e, svg); - const x = Math.min(PAD_L + plotW, Math.max(PAD_L, px)); - onaddlap(ct.competitor.competitor, Math.round(timeAt(x, span))); - } + // There is deliberately NO click-on-the-trace add-lap path: a bare svg click is un-labelled and + // misfires — every threshold drag that ends inside the svg makes the browser synthesize a click + // on it, and stray clicks land there too, each planting a phantom "Lap inserted" ruling (live + // 2026-07-03). The ONLY add path is the explicit "Add lap here" button in the cursor readout + // below the plot. // ── Draggable enter/exit threshold handles (the RH-style live tuning) ───────────────────────── // Only wired when `onthresholds` is supplied. A pointer drag on a threshold handle maps the @@ -432,21 +427,18 @@

    No samples captured for this node.

    {:else} {@const isHover = hover != null && hover.ref === ref} - - - + onHover(e, ct, span)} onmouseleave={clearHover} - onclick={(e: MouseEvent) => onTraceClick(e, ct, span)} > @@ -488,11 +480,7 @@ tabindex="0" aria-pressed={isSelected(ref, lap)} aria-label={`Lap ${lap.number} at ${formatMicros(lap.duration_micros)} — select`} - onclick={(e: MouseEvent) => { - // A marker click selects the lap; don't let it bubble to the SVG's add-lap handler. - e.stopPropagation(); - onselect(ref, lap); - }} + onclick={() => onselect(ref, lap)} onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -582,10 +570,10 @@ rssi {Math.round(hover.rssi)} {#if canControl && onaddlap} - + click: add lapadd lap ↓ below {/if} {/if} @@ -696,6 +684,9 @@ display: block; width: 100%; height: 13rem; + /* The crosshair cues the position READOUT only — a click on the plot never acts (adding a lap + is the explicit, labelled button below the plot). */ + cursor: crosshair; } .frame { stroke: rgba(255, 255, 255, 0.12); @@ -829,9 +820,6 @@ } /* Hover crosshair + readout (the "where exactly is this?" guide). */ - .plot.addable { - cursor: crosshair; - } .crosshair { stroke: #ffd24a; stroke-width: 1; diff --git a/frontend/apps/rd-console/src/lib/redetect.ts b/frontend/apps/rd-console/src/lib/redetect.ts index 72335bc..8c1b808 100644 --- a/frontend/apps/rd-console/src/lib/redetect.ts +++ b/frontend/apps/rd-console/src/lib/redetect.ts @@ -155,6 +155,58 @@ export function previewLaps(passTimes: number[]): PreviewLap[] { return laps; } +/** + * One row of the UNIFIED re-detection preview (see {@link previewRows}): a single chronological + * list that tells the whole story — the laps the tuned thresholds would produce (`kept` when the + * lap's closing pass matches a current official one, `added` when it's new), interleaved with the + * official passes the re-detection drops (`removed` — passes leaving the record, so they carry no + * lap number). + */ +export type PreviewRow = + | { + status: 'kept' | 'added'; + /** 1-based lap number in the previewed (post-commit) lap list. */ + number: number; + /** Source-clock time (µs) of the pass that closes this lap. */ + at: number; + /** Lap duration (µs). */ + durationMicros: number; + } + | { + status: 'removed'; + /** Source-clock time (µs) of the official pass the re-detection drops. */ + at: number; + /** The pass's log offset — the `VoidDetection` target a commit sends. */ + ref: number; + }; + +/** + * The unified re-detection preview: ONE chronological row list combining the surviving lap chain + * with the passes that would be removed. Laps derive from the detected passes exactly like + * {@link previewLaps}; each lap is `kept` when its CLOSING pass matched an official pass (within + * `toleranceMicros`, per {@link diffPasses}) and `added` otherwise. Every official pass the + * re-detection no longer sees interleaves as a `removed` row at its own time. Preview-only — + * nothing here sends commands. + */ +export function previewRows( + current: OfficialPass[], + detected: number[], + toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS +): PreviewRow[] { + const diff = diffPasses(current, detected, toleranceMicros); + const keptDetectedAt = new Set(diff.kept.map((k) => k.detectedAt)); + const rows: PreviewRow[] = previewLaps(detected).map((lap) => ({ + status: keptDetectedAt.has(lap.at) ? 'kept' : 'added', + number: lap.number, + at: lap.at, + durationMicros: lap.durationMicros + })); + for (const pass of diff.removed) rows.push({ status: 'removed', at: pass.at, ref: pass.ref }); + // Chronological (stable, so same-instant rows keep lap-then-removed order). + rows.sort((a, b) => a.at - b.at); + return rows; +} + /** * A competitor's current OFFICIAL passes, reconstructed from their (marshaling-corrected) lap * list: lap 1's opening pass (`start_ref`, at `lap1.at − lap1.duration`) plus every lap's diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 661dc02..11efbdb 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -71,7 +71,7 @@ detectPasses, diffPasses, officialPasses, - previewLaps + previewRows } from '../lib/redetect.js'; import { commandForAction } from '../lib/transitions.js'; import type { Session } from '../lib/session.svelte.js'; @@ -338,13 +338,15 @@ // ── Add a brand-new lap (not an edit of an existing one) ── // The `InsertLap` path adds a missed/never-detected crossing for a competitor at a source-clock // time — so it works even when the competitor has ZERO laps. Two entry points feed it the time: - // • the graph: a click on a competitor's trace passes the cursor's race-relative source-time; + // • the graph: the explicit "Add lap here" button in the cursor readout under the trace passes + // the cursor's race-relative source-time (never a bare click on the trace — stray clicks and + // drag-end synthesized clicks must not plant laps); // • the per-competitor control: an "Add lap" button at a typed time (seconds), for sim heats // with no trace/graph. // Role-gated by `canControl` like every other correction (the parent only renders these when the // session may control; the Director re-checks). - /** Add a lap for a competitor at an exact source-clock time (µs) — the graph-click path. */ + /** Add a lap for a competitor at an exact source-clock time (µs) — the graph's button path. */ async function insertLap(competitor: CompetitorRef, at: number): Promise { if (!canControl || !heat) return; const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at), heat)); @@ -589,7 +591,11 @@ ); const redetectDiff = $derived(diffPasses(officialPasses(shownPilotLaps), detectedPassTimes)); const redetectDirty = $derived(redetectDiff.added.length > 0 || redetectDiff.removed.length > 0); - const previewLapList = $derived(previewLaps(detectedPassTimes)); + // The UNIFIED preview: one chronological row list — kept/added laps interleaved with the + // official passes the re-detection drops (redetect.ts `previewRows`), so the whole story reads + // top-down instead of a confusing side-by-side of preview vs official lists. + const previewRowList = $derived(previewRows(officialPasses(shownPilotLaps), detectedPassTimes)); + const previewLapCount = $derived(previewRowList.filter((r) => r.status !== 'removed').length); function doResetThresholds(): void { if (!tuneTrace) return; @@ -823,19 +829,33 @@

    {:else}

    - Would be {previewLapList.length} lap{previewLapList.length === 1 ? '' : 's'} + Would be {previewLapCount} lap{previewLapCount === 1 ? '' : 's'} (+{redetectDiff.added.length} added, −{redetectDiff.removed.length} removed)

    {#if redetectDirty} +
      - {#each previewLapList as lap (lap.at)} -
    1. - Lap {lap.number} - {formatMicros(lap.durationMicros)} -
    2. + {#each previewRowList as row (row.status === 'removed' ? `x${row.ref}` : `l${row.at}`)} + {#if row.status === 'removed'} +
    3. + + pass at {formatMicros(row.at)}s — removed +
    4. + {:else} +
    5. + + Lap {row.number} + {formatMicros(row.durationMicros)} +
    6. + {/if} {/each}
    {/if} @@ -1416,29 +1436,53 @@ background: var(--gf-accent); color: #061018; } - .preview-laps { + /* The unified re-detection preview: one chronological list, big mono rows (sunlit-laptop + readable). Kept rows read plain; added rows carry the accent "+" chip styling; removed + rows read struck/dimmed danger — "this pass leaves the record on commit" at a glance. */ + .preview-rows { margin: var(--gf-space-2) 0 0; padding: 0; list-style: none; display: flex; - flex-wrap: wrap; - gap: var(--gf-space-2); + flex-direction: column; + gap: var(--gf-space-1); + max-width: 26rem; } - .preview-laps li { + .preview-rows li { display: flex; align-items: baseline; gap: var(--gf-space-2); padding: var(--gf-space-1) var(--gf-space-3); - border: 1px dashed color-mix(in srgb, var(--gf-accent) 55%, var(--gf-border)); + border: 1px solid transparent; border-radius: var(--gf-radius-sm); - background: var(--gf-surface-sunken); font-family: var(--gf-font-mono); font-size: var(--gf-font-size-sm); color: var(--gf-text-secondary); } - .preview-laps .lap-num { + .preview-rows .mark { + /* Fixed-width status column so the lap labels align across kept/added/removed rows. */ + min-width: 1ch; font-weight: var(--gf-font-weight-semibold); } + .preview-rows .lap-num { + font-weight: var(--gf-font-weight-semibold); + } + .preview-rows li.added { + border-color: color-mix(in srgb, var(--gf-accent) 55%, var(--gf-border)); + border-style: dashed; + background: var(--gf-surface-sunken); + color: var(--gf-text); + } + .preview-rows li.added .mark { + color: var(--gf-accent); + } + .preview-rows li.removed { + color: color-mix(in srgb, var(--gf-danger) 65%, var(--gf-text-muted)); + opacity: 0.8; + } + .preview-rows li.removed .what { + text-decoration: line-through; + } .result-actions { border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); } diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index c0593d1..1f7cfaa 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -484,7 +484,11 @@ describe('Marshaling (Slice 3)', () => { ); }); - it('clicking the trace adds a NEW lap at the cursor source-time (InsertLap)', async () => { + it('a trace click sends NOTHING — only the explicit "Add lap here" button inserts', async () => { + // No ruling may exist before an explicit action: the old click-to-insert path fired an + // immediate InsertLap on ANY svg click — including the click the browser synthesizes when + // a threshold drag ends inside the svg — planting phantom "Lap inserted" rulings + // (live 2026-07-03). const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList, @@ -507,11 +511,23 @@ describe('Marshaling (Slice 3)', () => { toJSON: () => ({}) } as DOMRect); - // ALICE's trace spans 0..90s over plotW=984 from PAD_L=8 → click at X=500 ≈ 45.0s. + // A bare click on the trace (X=500 ≈ 45.0s) sends no command at all. await fireEvent.click(svg, { clientX: 500 }); + expect(sendSpy).not.toHaveBeenCalled(); + + // The deliberate path still works: hover places the cursor, the labelled readout button + // inserts — the exact heat-tagged InsertLap at the cursor's source-time. + await fireEvent.mouseMove(svg, { clientX: 500 }); + await fireEvent.click( + within(graph).getByRole('button', { name: /Add lap for ALICE at .* seconds/ }) + ); expect(sendSpy).toHaveBeenCalledTimes(1); - const cmd = sendSpy.mock.calls[0][0] as { InsertLap: { competitor: string; at: number } }; + const cmd = sendSpy.mock.calls[0][0] as { + InsertLap: { adapter: string; competitor: string; at: number; heat: string }; + }; + expect(cmd.InsertLap.adapter).toBe('rh-1'); expect(cmd.InsertLap.competitor).toBe('ALICE'); + expect(cmd.InsertLap.heat).toBe('heat-1'); // X=500 → ((500-8)/984)*90s = 45.0s in source micros. expect(cmd.InsertLap.at).toBeGreaterThan(44_500_000); expect(cmd.InsertLap.at).toBeLessThan(45_500_000); @@ -1117,11 +1133,20 @@ describe('Marshaling (Slice 3)', () => { expect(screen.getByTestId('redetect-summary')).toHaveTextContent( 'Would be 3 laps (+1 added, −0 removed)' ); - // The preview lap list renders the would-be laps (the 41→60s split: 19s + 21s). - const previewList = screen.getByLabelText(/Preview laps for ALICE/); - expect(previewList).toHaveTextContent('Lap 2'); - expect(previewList).toHaveTextContent('19.000'); - expect(previewList).toHaveTextContent('21.000'); + // The UNIFIED preview list: one chronological story — the surviving laps with the new lap + // accent-marked. The 41→60s split makes lap 2 the ADDED one (19s), laps 1 + 3 kept. + const previewList = screen.getByLabelText(/Re-detection preview for ALICE/); + const rows = previewList.querySelectorAll('li'); + expect(rows).toHaveLength(3); + expect(rows[0].classList.contains('kept')).toBe(true); + expect(rows[0]).toHaveTextContent('Lap 1'); + expect(rows[0]).toHaveTextContent('40.000'); + expect(rows[1].classList.contains('added')).toBe(true); + expect(rows[1]).toHaveTextContent('+ Lap 2'); + expect(rows[1]).toHaveTextContent('19.000'); + expect(rows[2].classList.contains('kept')).toBe(true); + expect(rows[2]).toHaveTextContent('Lap 3'); + expect(rows[2]).toHaveTextContent('21.000'); // NOTHING was sent while adjusting — commit is explicit. expect(sendSpy).not.toHaveBeenCalled(); expect( @@ -1129,6 +1154,50 @@ describe('Marshaling (Slice 3)', () => { ).toBe(false); }); + it('passes the new levels DROP interleave as struck "removed" rows in the one preview list', async () => { + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + // Raise enter above every peak but the 132 one: only the 41s pass survives — the holeshot + // (1s) and the 81s gate pass leave the record. They render as removed rows, in time order, + // in the SAME list (no side-by-side preview-vs-official confusion). + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '131' } + }); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 0 laps (+0 added, −2 removed)' + ); + const previewList = screen.getByLabelText(/Re-detection preview for ALICE/); + const rows = previewList.querySelectorAll('li'); + expect(rows).toHaveLength(2); + expect(rows[0].classList.contains('removed')).toBe(true); + expect(rows[0]).toHaveTextContent('− pass at 1.000s — removed'); + expect(rows[1].classList.contains('removed')).toBe(true); + expect(rows[1]).toHaveTextContent('− pass at 1:21.000s — removed'); + // Still preview-only — nothing sent. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('finishing a threshold DRAG on the graph sends nothing (no phantom "Lap inserted")', async () => { + // The live bug (2026-07-03): releasing a threshold drag inside the svg makes the browser + // synthesize a click on it — the old click-to-insert path turned EVERY drag into an + // immediate InsertLap ruling, before any commit. + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + const handle = screen.getByRole('slider', { name: 'Enter threshold for ALICE' }); + // The full drag gesture (jsdom has no PointerEvent — a MouseEvent with the pointer type)… + fireEvent(handle, new MouseEvent('pointerdown', { bubbles: true, clientY: 40 })); + fireEvent(handle, new MouseEvent('pointermove', { bubbles: true, clientY: 30 })); + fireEvent(handle, new MouseEvent('pointerup', { bubbles: true })); + // …then the click the browser synthesizes on the svg where the pointer was released. + await fireEvent.click(svg, { clientX: 500, clientY: 30 }); + + // Rulings exist only after an explicit action — the drag+click sent NOTHING. + expect(sendSpy).not.toHaveBeenCalled(); + }); + it('flags equal/inverted thresholds instead of detecting', async () => { const { session } = renderTune(); render(Marshaling, { session }); diff --git a/frontend/apps/rd-console/tests/RssiGraph.test.ts b/frontend/apps/rd-console/tests/RssiGraph.test.ts index 67e3d89..5a8c81c 100644 --- a/frontend/apps/rd-console/tests/RssiGraph.test.ts +++ b/frontend/apps/rd-console/tests/RssiGraph.test.ts @@ -182,12 +182,14 @@ describe('RssiGraph hover readout', () => { }); /** - * Click-to-add-a-lap (gap 2): a click on a competitor's trace calls `onaddlap` with the cursor's - * source-time (the px→source-time mapping), role-gated by `canControl`. The explicit "Add lap here" - * button at the crosshair does the same for trackpad/keyboard users. + * Add-a-lap is EXPLICIT-ONLY: a bare click on the trace must never add a lap — the browser + * synthesizes a click on the svg when a threshold drag ends inside it, and stray clicks land there + * too; each used to plant a phantom "Lap inserted" ruling (live 2026-07-03). The only add path is + * the labelled "Add lap here" button in the cursor readout below the plot, role-gated by + * `canControl`. */ describe('RssiGraph add-lap', () => { - it('clicking the trace at a known X calls onaddlap with the matching source-time', async () => { + it('a click on the trace sends NOTHING — the trace has no click-to-insert path', async () => { const onaddlap = vi.fn(); render(RssiGraph, { trace: signalTrace, @@ -200,17 +202,12 @@ describe('RssiGraph add-lap', () => { const svg = screen.getByLabelText(/RSSI trace for ALICE/); pinSvgBox(svg); - // Click at the X mapping to 30.0s. + // Click at the X mapping to 30.0s — the old click-to-insert misfire surface. await fireEvent.click(svg, { clientX: xForTime(30_000_000) }); - expect(onaddlap).toHaveBeenCalledTimes(1); - const [ref, at] = onaddlap.mock.calls[0]; - expect(ref).toBe('ALICE'); - // The source-time is the cursor's race-relative instant (≈30.0s), within a rounding tolerance. - expect(at).toBeGreaterThan(29_900_000); - expect(at).toBeLessThan(30_100_000); + expect(onaddlap).not.toHaveBeenCalled(); }); - it('the "Add lap here" button adds at the cursor time', async () => { + it('the "Add lap here" button adds at the cursor time (the ONLY add path)', async () => { const onaddlap = vi.fn(); render(RssiGraph, { trace: signalTrace, @@ -231,7 +228,7 @@ describe('RssiGraph add-lap', () => { expect(onaddlap.mock.calls[0][1]).toBeLessThan(50_100_000); }); - it('clicking a lap marker selects (not adds) — the add-lap handler does not fire', async () => { + it('clicking a lap marker still selects (not adds) — the add-lap handler does not fire', async () => { const onaddlap = vi.fn(); const onselect = vi.fn(); render(RssiGraph, { @@ -257,7 +254,7 @@ describe('RssiGraph add-lap', () => { expect(svg.querySelector('.enter-line')).not.toBeNull(); }); - it('is review-only when canControl is false: a trace click does nothing', async () => { + it('is review-only when canControl is false: no "Add lap here" affordance is offered', async () => { const onaddlap = vi.fn(); render(RssiGraph, { trace: signalTrace, @@ -271,7 +268,7 @@ describe('RssiGraph add-lap', () => { pinSvgBox(svg); await fireEvent.click(svg, { clientX: xForTime(30_000_000) }); expect(onaddlap).not.toHaveBeenCalled(); - // …and no "Add lap here" affordance is offered. + // Hovering shows the readout but never the add button. await fireEvent.mouseMove(svg, { clientX: xForTime(30_000_000) }); expect(screen.queryByRole('button', { name: /Add lap for/ })).toBeNull(); }); @@ -349,6 +346,37 @@ describe('RssiGraph threshold tuning + preview', () => { expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 110, 85); }); + it('finishing a threshold DRAG never adds a lap (the browser-synthesized click is inert)', async () => { + // The live bug (2026-07-03): pointerdown→pointerup on a handle inside the svg makes the + // browser synthesize a CLICK on the svg — with the old click-to-insert path, EVERY threshold + // drag planted a phantom "Lap inserted". The synthesized click must send nothing. + const onaddlap = vi.fn(); + const onthresholds = vi.fn(); + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + onaddlap, + canControl: true, + onthresholds + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + pinSvgBox(svg); + + // The full drag gesture on the enter handle… + const handle = screen.getByRole('slider', { name: 'Enter threshold for ALICE' }); + await firePointer(handle, 'pointerdown', { clientY: yForValue(110) }); + await firePointer(handle, 'pointermove', { clientY: yForValue(120) }); + await firePointer(handle, 'pointerup', {}); + // …then the click the browser synthesizes on the svg where the pointer was released. + await fireEvent.click(svg, { clientX: xForTime(45_000_000), clientY: yForValue(120) }); + + // The drag tuned the thresholds — but NOTHING was inserted. + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 120, 95); + expect(onaddlap).not.toHaveBeenCalled(); + }); + it('arrow keys nudge the focused handle ±1 (keyboard access)', async () => { const onthresholds = vi.fn(); render(RssiGraph, { diff --git a/frontend/apps/rd-console/tests/redetect.test.ts b/frontend/apps/rd-console/tests/redetect.test.ts index c2af8e9..cd71d31 100644 --- a/frontend/apps/rd-console/tests/redetect.test.ts +++ b/frontend/apps/rd-console/tests/redetect.test.ts @@ -6,7 +6,8 @@ import { detectPasses, diffPasses, officialPasses, - previewLaps + previewLaps, + previewRows } from '../src/lib/redetect.js'; /** A uniform-grid trace: sample `i` at `i·period` (1s cadence by default). */ @@ -148,6 +149,61 @@ describe('previewLaps (consecutive-pass lap derivation)', () => { }); }); +describe('previewRows (the unified re-detection preview)', () => { + // The canonical official chain: holeshot at 1s, gate passes at 41s + 81s (two 40s laps). + const current = [ + { at: 1 * S, ref: 10 }, + { at: 41 * S, ref: 12 }, + { at: 81 * S, ref: 14 } + ]; + + it('classifies each preview lap by its CLOSING pass: matched official → kept, new → added', () => { + // The re-detection keeps the official chain and finds one NEW pass at 60s: the 41→60s lap + // closes on the new pass (added); the 1→41s and 60→81s laps close on matched passes (kept). + const rows = previewRows(current, [1 * S, 41 * S, 60 * S, 81 * S]); + expect(rows).toEqual([ + { status: 'kept', number: 1, at: 41 * S, durationMicros: 40 * S }, + { status: 'added', number: 2, at: 60 * S, durationMicros: 19 * S }, + { status: 'kept', number: 3, at: 81 * S, durationMicros: 21 * S } + ]); + }); + + it('interleaves dropped official passes as `removed` rows in time order (no lap number)', () => { + // The re-detection no longer sees the 41s pass: one long 1→81s lap remains (closing on the + // matched 81s pass → kept), and the dropped 41s pass interleaves BEFORE it at its own time. + const rows = previewRows(current, [1 * S, 81 * S]); + expect(rows).toEqual([ + { status: 'removed', at: 41 * S, ref: 12 }, + { status: 'kept', number: 1, at: 81 * S, durationMicros: 80 * S } + ]); + }); + + it('a pure-kept diff yields all-kept rows (nothing added, nothing removed)', () => { + const rows = previewRows(current, [1 * S, 41 * S, 81 * S]); + expect(rows).toEqual([ + { status: 'kept', number: 1, at: 41 * S, durationMicros: 40 * S }, + { status: 'kept', number: 2, at: 81 * S, durationMicros: 40 * S } + ]); + expect(rows.every((r) => r.status === 'kept')).toBe(true); + }); + + it('matches within tolerance like diffPasses — a re-timed pass still reads as kept', () => { + // The 41s pass re-detects 300ms later (within the 500ms default tolerance): same pass, so + // the lap closing on it stays `kept` (durations follow the DETECTED times, like previewLaps). + const rows = previewRows(current, [1 * S, 41 * S + 300_000, 81 * S]); + expect(rows.map((r) => r.status)).toEqual(['kept', 'kept']); + expect(rows[0]).toMatchObject({ at: 41 * S + 300_000, durationMicros: 40 * S + 300_000 }); + }); + + it('detecting nothing turns every official pass into a removed row (and no laps)', () => { + expect(previewRows(current, [])).toEqual([ + { status: 'removed', at: 1 * S, ref: 10 }, + { status: 'removed', at: 41 * S, ref: 12 }, + { status: 'removed', at: 81 * S, ref: 14 } + ]); + }); +}); + describe('officialPasses (lap list → boundary passes)', () => { it('reconstructs lap 1’s opening pass plus every closing pass', () => { const laps: Lap[] = [ From 0e14212daa8a3aac9e58568b6b663e74d35cbfce Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 00:43:30 +0000 Subject: [PATCH 326/362] =?UTF-8?q?fix(marshaling):=20an=20official=20(Fin?= =?UTF-8?q?al)=20result=20is=20locked=20=E2=80=94=20Revert=20to=20marshal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every result-changing marshaling command used to land silently on a Final heat and re-score the official result. The Director now rejects them with a typed BadRequest — `heat "" result is official (Final) — Revert it to marshal` — and the Marshaling screen mirrors the gate. Backend (crates/server/src/control_handler.rs): - `require_not_final` folds the heat with the same `heat::heat_state` the FSM checks use; applied to the heat-addressed commands (VoidHeat, ApplyPenalty, DeductPoints, tagged InsertLap). - Target-addressed commands (VoidDetection, AdjustLap, SplitLap, ThrowOutLap, ReverseRuling) resolve the target's OWNING heat first via the new `heat_of_offset` — by tag, by ruling-chain recursion, or positionally (the same `active`-cursor rule `app::heat_window_offsets` applies) — and lock on that. An unresolvable owner passes (nothing official to protect). - An untagged legacy InsertLap checks the positionally-active heat at the log tail (the heat the insertion would attribute to). - PROTESTS ARE EXEMPT: FileProtest and ResolveProtest change no result, so both stay legal on a Final heat (an upheld protest is acted on via Revert, where the open-protest Finalize gate composes correctly). - The heat-loop transitions (Revert/Discard/Restart, …) are untouched — Revert is the sanctioned re-open. Doc-comment table updated per command. Frontend (Marshaling.svelte): - On a Final heat a prominent banner — "This result is official — Revert it to make corrections. Protests may still be filed." — carries the working Revert affordance (the result-lifecycle control's handler + confirm semantics, relocated so there is exactly one). - Every result-changing surface is withheld: lap action buttons, the Add-lap control and the graph's add-lap path (onaddlap no longer passed down), the penalty form, the reverse-ruling picker, and Void heat. The protest file/resolve surfaces stay enabled (the exemption). The tune panel's sliders + preview stay LIVE; only Commit is disabled, its title explaining the lock. Tests: 6 new Rust tests (each command rejected on Final with the exact message + accepted after Revert, target-addressed resolution end to end, ruling-chain and untagged-tail cases, the protest exemption, and `heat_of_offset` unit coverage) and 4 new rd-console tests (banner + locked surfaces, the banner Revert working and the Unofficial flip re-enabling everything, protests enabled on Final, tune preview live with Commit locked). The audit contract now asserts the pre-Revert rejection over the wire, then Revert → rule → re-Finalize. Co-Authored-By: Claude Fable 5 --- crates/server/src/control_handler.rs | 509 +++++++++++++++++- .../rd-console/src/screens/Marshaling.svelte | 371 +++++++------ .../rd-console/tests/MarshalingScreen.test.ts | 150 ++++++ frontend/contract/audit.contract.ts | 26 +- 4 files changed, 879 insertions(+), 177 deletions(-) diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 0bb7113..e44cb29 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -18,17 +18,31 @@ //! | [`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`) | [`Event::DetectionVoided`] | -//! | [`Command::AdjustLap`] | the `target` offset exists and is a lap-gate pass (raw or synthetic, as above) | [`Event::LapAdjusted`] | -//! | [`Command::InsertLap`] | none (it adds a pass) | [`Event::LapInserted`] | -//! | [`Command::SplitLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) (the lap's ending pass) | [`Event::LapSplit`] | -//! | [`Command::VoidHeat`] | the heat exists in the log | [`Event::HeatVoided`] | -//! | [`Command::ApplyPenalty`] | the heat exists in the log | [`Event::PenaltyApplied`] | -//! | [`Command::DeductPoints`] | the heat exists in the log | [`Event::PenaltyApplied`] (`PointsDeducted`) | -//! | [`Command::ThrowOutLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::LapThrownOut`] | -//! | [`Command::FileProtest`] | the heat exists in the log | [`Event::ProtestFiled`] | -//! | [`Command::ResolveProtest`] | the `target` offset exists and is a [`Event::ProtestFiled`] | [`Event::ProtestResolved`] | -//! | [`Command::ReverseRuling`] | the `target` offset exists and is a reversible ruling (penalty / throw-out / protest resolution / heat-void) | [`Event::RulingReversed`] | +//! | [`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) //! @@ -947,19 +961,23 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_pass_target(state, target)?; + require_target_heat_not_final(state, target)?; Ok(Event::DetectionVoided { target }) } Command::AdjustLap { target, at } => { require_pass_target(state, target)?; + require_target_heat_not_final(state, 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)?; Ok(Event::LapSplit { target, at }) } Command::InsertLap { @@ -969,10 +987,25 @@ fn command_to_event(state: &AppState, command: Command) -> Result { // 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); an untagged one is a - // legacy client and attributes positionally, as before. - if let Some(h) = &heat { - require_scheduled_heat(state, h)?; + // 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. + match &heat { + Some(h) => { + require_scheduled_heat(state, h)?; + require_not_final(state, h)?; + } + 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, @@ -983,6 +1016,7 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; Ok(Event::HeatVoided { heat }) } Command::ApplyPenalty { @@ -991,6 +1025,7 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; Ok(Event::PenaltyApplied { heat, competitor, @@ -1004,6 +1039,7 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; Ok(Event::PenaltyApplied { heat, competitor, @@ -1016,9 +1052,12 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_lap_end_target(state, target)?; + require_target_heat_not_final(state, target)?; Ok(Event::LapThrownOut { target }) } - // File a protest against a heat result — the append-only filing fact. + // 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, @@ -1031,7 +1070,10 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_protest_target(state, target)?; Ok(Event::ProtestResolved { target, outcome }) @@ -1039,8 +1081,11 @@ fn command_to_event(state: &AppState, command: Command) -> Result { // 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). + // 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 }) } } @@ -1121,6 +1166,105 @@ fn require_scheduled_heat(state: &AppState, heat: &HeatId) -> Result<(), Protoco } } +/// 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()), + 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 @@ -2270,6 +2414,333 @@ mod tests { 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() { diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 11efbdb..d05a400 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -119,6 +119,13 @@ ); // The marshaled heat's loop phase — drives which result transition (Finalize / Revert) is offered. const marshalPhase = $derived(marshalLive?.phase); + // An OFFICIAL (Final) result is LOCKED: the Director rejects every result-changing marshaling + // command on it (`require_not_final`, control_handler.rs) — mirror that gate here so the screen + // never offers a correction the server will bounce. Revert (the sanctioned re-open) is surfaced + // prominently in the lock banner; PROTESTS stay available (filing/resolving changes no result, + // so the Director exempts them). Inspection (laps, audit, tune preview) stays live too — + // committing is what's locked. + const resultLocked = $derived(marshalPhase === 'Final'); // The result lifecycle (marshaling Slice 5): Provisional (Unofficial) vs Official (Final), with the // auto-official countdown when the round armed a protest window. The Finalize/Revert transitions // below now act on it from here too (B) — targeting the marshaled heat, never Race Control's. @@ -348,7 +355,7 @@ /** Add a lap for a competitor at an exact source-clock time (µs) — the graph's button path. */ async function insertLap(competitor: CompetitorRef, at: number): Promise { - if (!canControl || !heat) return; + if (!canControl || resultLocked || !heat) return; const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at), heat)); if (ack.ok) await afterCorrection(); } @@ -610,7 +617,16 @@ // (the error banner shows; the refresh reflects whatever landed). let committing = $state(false); async function doCommitRedetect(): Promise { - if (!canControl || !heat || !tuneTrace || !tuneValid || !redetectDirty || committing) return; + if ( + !canControl || + resultLocked || + !heat || + !tuneTrace || + !tuneValid || + !redetectDirty || + committing + ) + return; committing = true; try { const key = tuneTrace.competitor; @@ -769,7 +785,7 @@ laps={shownLaps} {selected} onselect={selectLap} - onaddlap={insertLap} + onaddlap={resultLocked ? undefined : insertLap} {canControl} nameFor={competitorName} onthresholds={tuning ? onGraphThresholds : undefined} @@ -811,16 +827,20 @@ + Commit re-detection {#if !tuneValid} @@ -898,156 +918,176 @@ {/if} {#if canControl} - -
    - - {#if selected} - Selected: {competitorName(selected.competitor)} · Lap {selected.lap.number} - {:else} - Select a lap to correct - {/if} - -
    - - - - - - + {#if resultLocked} + +
    +

    + This result is official — Revert it to make corrections. Protests may still be filed. +

    + Revert → Unofficial
    -
    + {:else} + +
    + + {#if selected} + Selected: {competitorName(selected.competitor)} · Lap {selected.lap.number} + {:else} + Select a lap to correct + {/if} + +
    + + + + + + +
    +
    - -
    - Add a lap -

    - {#if hasTrace} - Click a competitor's trace on the graph to add a lap at that time, or add one at a - typed time here. - {:else} - Add a missed lap at a typed time (source clock) — works even with no laps yet. - {/if} -

    -
    - - - -
    -
    - - -
    - Competitor ruling -
    - - - {#if penaltyKind === 'time'} - - - {:else if penaltyKind === 'points'} +
    + Add a lap +

    + {#if hasTrace} + Click a competitor's trace on the graph to add a lap at that time, or add one at a + typed time here. + {:else} + Add a missed lap at a typed time (source clock) — works even with no laps yet. + {/if} +

    +
    + - {:else} + +
    +
    + + +
    + Competitor ruling +
    + + {#if penaltyKind === 'time'} + + + {:else if penaltyKind === 'points'} + + {:else} + + {/if} + +
    + {#if penaltyKind === 'points'} +

    + Points affect season / event standings, not this heat's laps. +

    {/if} - -
    - {#if penaltyKind === 'points'} -

    Points affect season / event standings, not this heat's laps.

    - {/if} -
    - - -
    -
    +
    + + +
    + + {/if} - +
    Protests
    @@ -1120,8 +1160,9 @@ : undefined}>Finalize → Official {:else if marshalPhase === 'Final'} -

    Official — revert to re-open the result for correction.

    - Revert → Unofficial + +

    Official — use Revert (in the banner above) to re-open the result.

    {:else}

    No result to finalize yet — this heat hasn’t finished (it’s {marshalPhase ?? @@ -1130,13 +1171,15 @@ {/if}

    -
    - Void the heat -

    Throws out the whole heat — it will not count.

    - - Void heat - -
    + {#if !resultLocked} +
    + Void the heat +

    Throws out the whole heat — it will not count.

    + + Void heat + +
    + {/if} {/if} @@ -1230,6 +1273,26 @@ color: var(--gf-text); font-size: var(--gf-font-size-sm); } + /* The official-result lock banner: prominent (the field-readability bar), in the Official + violet so it reads as the same lifecycle the header badge shows, with the Revert affordance + right in it. */ + .official-lock { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid color-mix(in srgb, var(--gf-phase-scored) 45%, var(--gf-border)); + border-radius: var(--gf-radius-md); + background: color-mix(in srgb, var(--gf-phase-scored) 14%, var(--gf-elevated)); + } + .official-lock p { + margin: 0; + color: var(--gf-text); + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-semibold); + } .layout { display: grid; grid-template-columns: 2fr 1fr; diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 1f7cfaa..be9aeaf 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -374,6 +374,129 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ Revert: { heat: 'heat-1' } }); }); + // ── The official-result lock: a Final heat rejects result-changing corrections ─────────────── + // + // The Director now bounces every result-changing marshaling command on a Final heat + // (`require_not_final`, control_handler.rs) — the screen mirrors the gate: a prominent banner + // carrying the WORKING Revert affordance, every correction surface withheld, protests exempt + // (filing/resolving changes no result), and inspection (laps / audit / tune preview) still live. + describe('official (Final) result lock', () => { + const FINAL_LIVE: LiveRaceState = { ...liveRunning, phase: 'Final', lifecycle: 'Official' }; + + it('shows the lock banner and withholds every correction surface on a Final heat', async () => { + const { session, sendSpy } = makeTestSession({ + live: FINAL_LIVE, + laps: lapList, + audit: marshalingAudit, + signal: signalTrace + }); + render(Marshaling, { session }); + + // The banner, with the Revert affordance IN it. + const banner = screen.getByRole('status', { name: 'Official result lock' }); + expect(banner).toHaveTextContent( + 'This result is official — Revert it to make corrections. Protests may still be filed.' + ); + expect(within(banner).getByRole('button', { name: /Revert/ })).toBeInTheDocument(); + + // Laps + audit still render (inspection is fine)… + expect(screen.getByRole('button', { name: /Lap 1\s*41\.000/ })).toBeInTheDocument(); + expect(screen.getByText('CARMEN · DQ applied')).toBeInTheDocument(); + + // …but every result-changing surface is gone: the lap action buttons… + expect(screen.queryByRole('button', { name: 'Remove (void)' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Split' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Edit time' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Throw out lap' })).toBeNull(); + // …the Add-lap control… + expect(screen.queryByRole('button', { name: 'Add lap' })).toBeNull(); + expect(screen.queryByLabelText('Add-lap competitor')).toBeNull(); + // …the penalty form + the reverse-ruling picker… + expect(screen.queryByRole('button', { name: 'Apply' })).toBeNull(); + expect(screen.queryByLabelText('Reverse ruling')).toBeNull(); + // …and the heat-void danger zone. + expect(screen.queryByRole('button', { name: 'Void heat' })).toBeNull(); + + // The graph's add-lap path is unwired too: hovering the trace offers NO "Add lap" button + // (the parent stops passing `onaddlap` down while the result is locked). + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 1000, + bottom: 220, + width: 1000, + height: 220, + x: 0, + y: 0, + toJSON: () => ({}) + } as DOMRect); + await fireEvent.mouseMove(svg, { clientX: 500 }); + expect(screen.queryByRole('button', { name: /Add lap for ALICE/ })).toBeNull(); + + // Nothing was sent by any of the above. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('the banner Revert WORKS — it sends the command, and the Unofficial flip re-enables everything', async () => { + const { session, sendSpy, pushLive } = makeTestSession({ live: FINAL_LIVE, laps: lapList }); + render(Marshaling, { session }); + + // One click in the banner performs the Revert (same confirm semantics as the lifecycle + // control — it reuses the same handler). + const banner = screen.getByRole('status', { name: 'Official result lock' }); + await fireEvent.click(within(banner).getByRole('button', { name: /Revert/ })); + expect(sendSpy).not.toHaveBeenCalled(); // confirm-first (destructive re-open) + await fireEvent.click(within(banner).getByRole('button', { name: 'Confirm' })); + expect(sendSpy).toHaveBeenCalledWith({ Revert: { heat: 'heat-1' } }); + + // The phase flips to Unofficial (the stream re-folds): the lock lifts — banner gone, + // correction surfaces back. + pushLive({ ...liveRunning, phase: 'Unofficial', lifecycle: { Provisional: {} } }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Remove (void)' })).toBeInTheDocument() + ); + expect(screen.queryByRole('status', { name: 'Official result lock' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Add lap' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Void heat' })).toBeInTheDocument(); + }); + + it('protests stay ENABLED on a Final heat — file and resolve both send (the exemption)', async () => { + const audit: AuditEntry[] = [ + { + kind: 'ProtestFiled', + at: 1_700_000_000_000_000, + at_ref: 22, + competitor: 'BOB', + summary: 'Protest filed: contact' + } + ]; + const { session, sendSpy } = makeTestSession({ live: FINAL_LIVE, laps: lapList, audit }); + render(Marshaling, { session }); + + // Filing against the official result needs no Revert. + await fireEvent.change(screen.getByLabelText('Protest competitor'), { + target: { value: 'BOB' } + }); + await fireEvent.input(screen.getByLabelText('Protest note'), { + target: { value: 'contact on lap 2' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'File protest' })); + expect(sendSpy).toHaveBeenCalledWith({ + FileProtest: { heat: 'heat-1', competitor: 'BOB', note: 'contact on lap 2' } + }); + + // Resolving (e.g. denying) one needs no Revert either. + await fireEvent.change(screen.getByLabelText('Resolve protest'), { target: { value: '22' } }); + await fireEvent.change(screen.getByLabelText('Protest outcome'), { + target: { value: 'Denied' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Resolve protest' })); + expect(sendSpy).toHaveBeenCalledWith({ ResolveProtest: { target: 22, outcome: 'Denied' } }); + }); + }); + // ── The Recent-rulings strip (the full trail moved to the event-wide Audit page) ───────────── it('the Recent-rulings strip renders the latest entries newest-first (composed lines)', () => { const { session } = makeTestSession({ @@ -1244,6 +1367,33 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenNthCalledWith(2, { VoidDetection: { target: 14 } }); }); + it('keeps the tune PREVIEW live on a Final heat but locks Commit with the explaining title', async () => { + // Inspection is fine on an official result — the sliders + preview stay live — but + // COMMITTING would re-score it, so the button is disabled with the revert-first title. + const { session, sendSpy } = makeTestSession({ + live: { ...liveRunning, phase: 'Final', lifecycle: 'Official' }, + laps: TUNE_LAPS, + signal: TUNE_TRACE + }); + render(Marshaling, { session }); + + // The preview still re-detects live at the adjusted level (a dirty +1 diff)… + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '100' } + }); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 3 laps (+1 added, −0 removed)' + ); + // …but Commit is locked, and the title says why. + const commit = screen.getByRole('button', { + name: 'Commit re-detection' + }) as HTMLButtonElement; + expect(commit.disabled).toBe(true); + expect(commit.title).toBe('This result is official — Revert it to make corrections.'); + await fireEvent.click(commit); + expect(sendSpy).not.toHaveBeenCalled(); + }); + it('Reset restores the recorded thresholds and clears the preview diff', async () => { const { session, sendSpy } = renderTune(); render(Marshaling, { session }); diff --git a/frontend/contract/audit.contract.ts b/frontend/contract/audit.contract.ts index 1429fba..316249e 100644 --- a/frontend/contract/audit.contract.ts +++ b/frontend/contract/audit.contract.ts @@ -2,8 +2,10 @@ * Event-wide audit contract: `GET /events/{id}/audit` plus the real `eventAudit` client helper. * * Drives a real Director end-to-end: directory setup (class + pilots + round), schedule + run + - * finalize a heat, then two marshaling rulings — a penalty (heat-tagged) and a detection void - * (target-addressed, aimed at a real pass offset read back from the laps projection). The + * finalize a heat — where a ruling now BOUNCES (an official Final result is locked; the typed + * revert-first BadRequest is asserted) — then Revert and two marshaling rulings: a penalty + * (heat-tagged) and a detection void (target-addressed, aimed at a real pass offset read back + * from the laps projection), re-finalizing after. The * event-wide audit must then serve both rulings through the real `@gridfpv/protocol-client`, * each **tagged with the heat** it rules on and in **newest-first** order (descending global * append offset) — the same window-attributed entries Marshaling's per-heat `?projection=audit` @@ -90,8 +92,23 @@ describe('GET /events/{id}/audit serves the heat-tagged, newest-first event audi expect((await rdControl(director.baseUrl, TOKEN, { ForceEnd: { heat: HEAT } })).ok).toBe(true); expect((await rdControl(director.baseUrl, TOKEN, { Finalize: { heat: HEAT } })).ok).toBe(true); - // Two rulings on the finalized heat: a heat-tagged penalty, then a target-addressed void - // aimed at a REAL pass offset (pilot A's first lap's end pass, read from the laps projection). + // The heat is now OFFICIAL (Final) — a ruling on it must be REJECTED with the revert-first + // gate (`require_not_final`, control_handler.rs): an official result never re-scores silently. + const locked = await rdControl(director.baseUrl, TOKEN, { + ApplyPenalty: { + heat: HEAT, + competitor: pilotB.id, + penalty: { Disqualify: { reason: 'contract: flew the wrong course' } } + } + }); + expect(locked.ok).toBe(false); + expect(locked.error?.code).toBe('BadRequest'); + expect(locked.error?.message).toContain('official (Final)'); + + // Revert (the sanctioned re-open), THEN the two rulings land: a heat-tagged penalty and a + // target-addressed void aimed at a REAL pass offset (pilot A's first lap's end pass, read + // from the laps projection). Re-finalize afterwards — the corrected result goes official. + expect((await rdControl(director.baseUrl, TOKEN, { Revert: { heat: HEAT } })).ok).toBe(true); expect( ( await rdControl(director.baseUrl, TOKEN, { @@ -107,6 +124,7 @@ describe('GET /events/{id}/audit serves the heat-tagged, newest-first event audi expect( (await rdControl(director.baseUrl, TOKEN, { VoidDetection: { target: voidTarget } })).ok ).toBe(true); + expect((await rdControl(director.baseUrl, TOKEN, { Finalize: { heat: HEAT } })).ok).toBe(true); // The event-wide audit, through the real client helper. const trail = await eventAudit(director.baseUrl, PRACTICE_EVENT_ID); From f9bd1de483b1ed8a15fa295537c87b03d323e6b3 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 01:03:51 +0000 Subject: [PATCH 327/362] chore(console): drop the base-URL readout from the sidebar foot It served no purpose for the RD (the address is in the browser bar), and the sidebar reads cleaner without it. The orphaned .sidebar-foot/.base styles and the narrow-viewport rule go with it. Co-Authored-By: Claude Fable 5 --- frontend/apps/rd-console/src/App.svelte | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 969895f..91a86f3 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -312,10 +312,6 @@ {/each} - -
    @@ -571,20 +567,6 @@ color: var(--gf-text-faint); } - .sidebar-foot { - margin-top: auto; - display: flex; - flex-direction: column; - gap: var(--gf-space-3); - padding: var(--gf-space-3) var(--gf-space-2) 0; - border-top: 1px solid var(--gf-border-subtle); - } - .base { - font-size: var(--gf-font-size-2xs); - color: var(--gf-text-faint); - word-break: break-all; - } - /* ── Main column (topbar + content) ──────────────────────────────────────── */ .main-col { display: flex; @@ -713,8 +695,7 @@ grid-template-columns: 4rem 1fr; } .nav-label, - kbd, - .base { + kbd { display: none; } .nav-item { From 7b7bf25d51f93f4036b67dfa7ff2ed133c7d1f60 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 01:09:19 +0000 Subject: [PATCH 328/362] feat(audio): lap callouts + the race tone model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the console audio into two clearly-scoped layers (user-specced v1): PROCEDURE TONES — always on, the old "Tone on/off" toggle is removed (it was a testing convenience and silenced the race procedure itself): - Start tone: default lengthened 400ms -> 800ms (still 880Hz square; a configured StartProcedure.tone cue still wins). - NEW end-of-race countdown for heats with a KNOWN fixed end (a Timed win-condition round's window_micros; a Practice run's time_limit_secs): a short 880Hz pip at remaining 5..1s and a lower (440Hz), longer (~600ms) race-end buzzer at 0. Scheduling derives from the same server-anchored clock the heat timer uses (session.serverNowMs vs race_started_at) and fires each mark once per heat run (keyed heat + race_started_at; a mid-race re-mount pre-marks already-past pips silently — no replays). The buzzer marks the WINDOW end; grace-window laps/scoring untouched. INFORMATIONAL LAYER — what the mute now scopes to, renamed "Callouts": - NEW lap callouts: each new lap on the current RUNNING heat (live-stream count increases only — late joins baseline silently; corrections and non-current/finished heats never call out) plays an immediate distinct crossing pip (1760Hz, 60ms) and queues a spoken ", lap N, M.S" via speechSynthesis. Callsigns resolve through the shared createCompetitorNameResolver; an unresolved ref skips the name rather than speaking a raw id (friendly-names rule). - Speech is serialized FIFO; a backlog deeper than 3 coalesces to ", lap N" to catch up; the queue cancels on heat end and on mute. The mute pref moves to a new key (gridfpv.callouts.muted) since its meaning changed. - Audio unlock: with the toggle gone, a one-time document pointerdown listener (plus the existing Stage/Start handlers) resumes the AudioContext so procedure tones are never blocked by autoplay policy. Structure: startTone.ts -> raceAudio.ts (RaceAudioPlayer, injectable AudioContext seam); new callouts.ts (CalloutQueue, injectable speech seam), endTones.svelte.ts (useEndOfRaceTones), lapCallouts.svelte.ts (useLapCallouts); wired in LiveRaceControl. jsdom-safe: all platform audio/speech behind seams; 99 tests across the five touched suites (588 total green), svelte-check both passes clean, lint+prettier clean. Co-Authored-By: Claude Fable 5 --- frontend/apps/rd-console/src/lib/callouts.ts | 163 ++++++++ .../rd-console/src/lib/endTones.svelte.ts | 114 ++++++ .../rd-console/src/lib/lapCallouts.svelte.ts | 90 +++++ frontend/apps/rd-console/src/lib/raceAudio.ts | 288 +++++++++++++++ frontend/apps/rd-console/src/lib/startTone.ts | 238 ------------ .../src/screens/LiveRaceControl.svelte | 118 +++++- .../rd-console/tests/LiveRaceControl.test.ts | 349 +++++++++++++++--- .../apps/rd-console/tests/callouts.test.ts | 160 ++++++++ .../rd-console/tests/endTones.svelte.test.ts | 186 ++++++++++ .../tests/lapCallouts.svelte.test.ts | 168 +++++++++ .../apps/rd-console/tests/raceAudio.test.ts | 288 +++++++++++++++ .../apps/rd-console/tests/startTone.test.ts | 194 ---------- 12 files changed, 1859 insertions(+), 497 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/callouts.ts create mode 100644 frontend/apps/rd-console/src/lib/endTones.svelte.ts create mode 100644 frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts create mode 100644 frontend/apps/rd-console/src/lib/raceAudio.ts delete mode 100644 frontend/apps/rd-console/src/lib/startTone.ts create mode 100644 frontend/apps/rd-console/tests/callouts.test.ts create mode 100644 frontend/apps/rd-console/tests/endTones.svelte.test.ts create mode 100644 frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts create mode 100644 frontend/apps/rd-console/tests/raceAudio.test.ts delete mode 100644 frontend/apps/rd-console/tests/startTone.test.ts diff --git a/frontend/apps/rd-console/src/lib/callouts.ts b/frontend/apps/rd-console/src/lib/callouts.ts new file mode 100644 index 0000000..f415fd3 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/callouts.ts @@ -0,0 +1,163 @@ +/** + * Spoken **lap callouts** — the voice half of the informational audio layer (`raceAudio.ts` has + * the beeps). On each recorded lap the console speaks "‹callsign›, lap ‹N›, ‹M.S›" through the + * Web Speech API (`speechSynthesis`), so the RD hears who crossed without looking down. + * + * ── Queue with coalescing (pack finishes) ──────────────────────────────────────────────────── + * Speech is slow (~1–2s per utterance) while crossings can arrive in a burst (a pack crossing the + * gate together). Crossing *beeps* fire immediately per lap (raceAudio); the *speech* here is + * serialized — one utterance at a time, FIFO — and when the backlog exceeds + * {@link COALESCE_DEPTH} the queued entries fall back to their **short** form ("‹callsign›, + * lap ‹N›", no lap time) so the voice catches back up instead of narrating history. + * {@link CalloutQueue.cancel} drops the backlog and stops the current utterance — called when the + * heat ends or the RD mutes the callouts. + * + * ── Testability ────────────────────────────────────────────────────────────────────────────── + * jsdom has no `speechSynthesis`/`SpeechSynthesisUtterance`, so the platform calls sit behind an + * injected {@link CalloutSpeech} seam; unit tests drive a fake synth's `onend` and assert the + * FIFO/coalesce/cancel behaviour. Where the platform has no speech at all, every call is a silent + * no-op — callouts never block the UI. + */ + +/** The minimal utterance surface the queue drives (a `SpeechSynthesisUtterance` in production). */ +export interface SpeechUtteranceLike { + text: string; + onend: (() => void) | null; + onerror: (() => void) | null; +} + +/** The minimal `speechSynthesis` surface the queue drives. */ +export interface SpeechSynthesisLike { + speak(utterance: SpeechUtteranceLike): void; + cancel(): void; +} + +/** The injectable speech seam: the synth plus its utterance factory. */ +export interface CalloutSpeech { + synth: SpeechSynthesisLike; + makeUtterance(text: string): SpeechUtteranceLike; +} + +/** One queued callout: the full text, and the short form used when the queue is backed up. */ +export interface CalloutTexts { + full: string; + short: string; +} + +/** + * Beyond this many waiting entries the queue speaks **short** forms to catch up (pack finishes + * would otherwise leave the voice narrating laps long since crossed). + */ +const COALESCE_DEPTH = 3; + +/** Read the platform speech seam, or `undefined` where the Web Speech API is unavailable. */ +function platformSpeech(): CalloutSpeech | undefined { + const g = globalThis as unknown as { + speechSynthesis?: SpeechSynthesisLike; + SpeechSynthesisUtterance?: new (text: string) => SpeechUtteranceLike; + }; + if (!g.speechSynthesis || !g.SpeechSynthesisUtterance) return undefined; + const Utterance = g.SpeechSynthesisUtterance; + const synth = g.speechSynthesis; + return { synth, makeUtterance: (text) => new Utterance(text) }; +} + +/** + * The serialized lap-callout speech queue. One instance per console (created in the live screen). + * `enqueue` is safe to call from any crossing; utterances play one at a time in arrival order. + */ +export class CalloutQueue { + #speech: CalloutSpeech | undefined; + #queue: CalloutTexts[] = []; + /** The utterance currently being spoken — the serialization token (also guards a stale `onend` + * from a cancelled utterance pumping a second, concurrent chain). */ + #current: SpeechUtteranceLike | undefined; + + constructor(speech?: CalloutSpeech) { + this.#speech = speech ?? platformSpeech(); + } + + /** Whether this environment can speak at all (the Web Speech API is available). */ + get available(): boolean { + return this.#speech !== undefined; + } + + /** Waiting entries (excluding the utterance currently speaking). Exposed for tests/telemetry. */ + get depth(): number { + return this.#queue.length; + } + + /** Queue one callout (FIFO). A silent no-op where speech is unavailable. Never throws. */ + enqueue(texts: CalloutTexts): void { + if (!this.#speech) return; + this.#queue.push(texts); + this.#pump(); + } + + /** + * Drop the backlog and stop the current utterance — the heat ended or the RD muted the + * callouts; nothing queued is worth saying any more. Idempotent; never throws. + */ + cancel(): void { + this.#queue = []; + this.#current = undefined; + try { + this.#speech?.synth.cancel(); + } catch { + /* a flaky speech backend must never break the live screen */ + } + } + + /** Speak the next queued entry unless one is already speaking. */ + #pump(): void { + if (!this.#speech || this.#current) return; + // Backed up beyond the coalesce depth (a pack finish): speak the short form to catch up. + // Measured BEFORE dequeuing — "more than COALESCE_DEPTH waiting" is the backlog signal. + const backlogged = this.#queue.length > COALESCE_DEPTH; + const next = this.#queue.shift(); + if (!next) return; + const text = backlogged ? next.short : next.full; + try { + const utterance = this.#speech.makeUtterance(text); + const done = () => { + // A cancel() (or a newer utterance) may have superseded this one — a stale onend must not + // pump a second, concurrent chain. + if (this.#current !== utterance) return; + this.#current = undefined; + this.#pump(); + }; + utterance.onend = done; + utterance.onerror = done; + this.#current = utterance; + this.#speech.synth.speak(utterance); + } catch { + // The utterance never started — clear the token so the queue isn't wedged. + this.#current = undefined; + } + } +} + +/** Format a lap duration (µs) the way it is spoken: whole-plus-tenth seconds, e.g. `"21.4"`. */ +export function formatLapSeconds(micros: number): string { + return (micros / 1_000_000).toFixed(1); +} + +/** + * Build the spoken texts for one lap crossing: the full "‹callsign›, lap ‹N›, ‹M.S›" and the + * short (coalesced) "‹callsign›, lap ‹N›". + * + * `callsign` is the **resolved friendly name** (the shared competitor-name resolver) — pass + * `undefined` when the resolver fell back to the raw ref, and the callout skips the name rather + * than speaking an id (the friendly-names rule). `lastLapMicros` absent (no completed lap time) + * also degrades to the short form. + */ +export function lapCalloutTexts( + callsign: string | undefined, + lap: number, + lastLapMicros?: number +): CalloutTexts { + const name = callsign ? `${callsign}, ` : ''; + const short = `${name}lap ${lap}`; + const full = lastLapMicros != null ? `${short}, ${formatLapSeconds(lastLapMicros)}` : short; + return { full, short }; +} diff --git a/frontend/apps/rd-console/src/lib/endTones.svelte.ts b/frontend/apps/rd-console/src/lib/endTones.svelte.ts new file mode 100644 index 0000000..490b5a6 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/endTones.svelte.ts @@ -0,0 +1,114 @@ +/** + * The **end-of-race tone scheduler** — a sibling of the race / arming clocks + * ({@link useRaceClock} / {@link useArmingClock}) built on the same phase-driven `$effect` + * pattern, deciding *when* the countdown pips and the race-end buzzer fire (the sounds themselves + * live in `raceAudio.ts`). + * + * Only heats with a **known fixed end** get a countdown: the caller derives the window length — + * a `Timed` win-condition round's `window_micros`, or a Practice run's `time_limit_secs` — and + * passes it here (anything else, e.g. First-to-N, passes `undefined` and nothing ever fires). + * While the heat is `Running` with a server `race_started_at`, the end instant is + * `race_started_at + window`; at remaining **5, 4, 3, 2, 1 s** it fires `onCountdown(n)` and at + * remaining **0** it fires `onRaceEnd()`. The buzzer marks the *window* end — the grace window + * keeps the heat `Running` for late crossings after it, and scoring is untouched. + * + * ── Server-anchored, like the heat clock ───────────────────────────────────────────────────── + * The schedule derives from the SAME anchor the heat-time readout uses: the server's + * `race_started_at` (µs) compared against `nowMs()` — the offset-corrected server clock + * (`session.serverNowMs()`), never a raw `Date.now()` anchor. The local `setInterval` only paces + * the re-check (each tick re-reads the anchored clock), so timer drift between ticks cannot skew + * the schedule — the standing clock rule for a console on a separate device. + * + * ── Once per heat RUN — no replays ─────────────────────────────────────────────────────────── + * Each mark fires at most once per run, keyed by `heat + race_started_at` (the run anchor — a + * Restart mints a new `race_started_at`, so the next run counts down afresh; mirrors how the + * start tone keys off the heat). Two replay guards: + * • live-stream re-renders re-run the `$effect` with the same run key — the fired-marks set + * lives *outside* the effect, so nothing re-fires; + * • a re-mount mid-race (navigating away and back) starts a fresh instance — on first sight of + * a run, every mark **already due** is pre-marked *silently*, so mounting at remaining 3.4s + * doesn't machine-gun the 5s and 4s pips; the still-future marks fire on time. + */ + +/** The countdown marks (ms remaining) that get a pip; `0` (the buzzer) is handled separately. */ +const COUNTDOWN_MARKS_MS = [5000, 4000, 3000, 2000, 1000]; + +/** How often the schedule re-checks the anchored clock (ms). Pips land within a tick of true. */ +const TICK_MS = 100; + +export interface EndOfRaceToneCues { + /** A countdown pip is due: `secondsRemaining` ∈ 5…1. */ + onCountdown(secondsRemaining: number): void; + /** The race window just ended (remaining 0): the race-end buzzer. */ + onRaceEnd(): void; +} + +/** + * Schedule the end-of-race tones for the live heat. All getters are read reactively: + * • `getPhase` / `getHeat` — the live phase + current heat (tones only while `Running`); + * • `getStartedAtMicros` — the server race-go instant (`race_started_at`, µs); + * • `getWindowMicros` — the fixed window length (µs), or `undefined` for a heat with no known + * fixed end (no countdown, ever); + * • `nowMs` — the **server-anchored** clock (`session.serverNowMs()`). + * Must be called during component setup so its internal `$effect` is owned (torn down on + * unmount). + */ +export function useEndOfRaceTones( + getPhase: () => string | undefined, + getHeat: () => string | undefined, + getStartedAtMicros: () => number | null | undefined, + getWindowMicros: () => number | undefined, + nowMs: () => number, + cues: EndOfRaceToneCues +): void { + // The once-per-run guard, OUTSIDE the $effect: stream pushes re-run the effect with the same + // run key, and the fired set must survive those re-runs (a per-run set inside the effect would + // replay every mark on every push). + let runKey: string | undefined; + let fired = new Set(); // COUNTDOWN_MARKS_MS entries + 0 (the buzzer) + + $effect(() => { + const phase = getPhase(); + const heat = getHeat(); + const startedAtMicros = getStartedAtMicros(); + const windowMicros = getWindowMicros(); + // No fixed end / not running / no server anchor yet → nothing scheduled. (Leaving `Running` + // clears the interval via the effect cleanup; an early ForceEnd simply never reaches 0 here.) + if (phase !== 'Running' || heat === undefined || startedAtMicros == null) return; + if (windowMicros === undefined) return; + + const endMs = (startedAtMicros + windowMicros) / 1000; + const key = `${heat}@${startedAtMicros}`; + if (key !== runKey) { + // First sight of this run (a fresh race-go, a Restart's new anchor, or a re-mount mid-race). + // Pre-mark every mark ALREADY due, silently — a genuine race-go has the full window ahead so + // nothing pre-marks; a mid-race re-mount skips the pips that already sounded. + runKey = key; + fired = new Set(); + // STRICTLY past marks pre-mark (a mark due exactly now hasn't sounded anywhere yet — let + // the first advance() below fire it). + const remaining = endMs - nowMs(); + for (const mark of COUNTDOWN_MARKS_MS) if (remaining < mark) fired.add(mark); + if (remaining < 0) fired.add(0); + } + + const advance = () => { + // Re-read the ANCHORED clock every tick — the interval only paces the check, so local timer + // drift between ticks cannot skew when a mark fires. + const remaining = endMs - nowMs(); + for (const mark of COUNTDOWN_MARKS_MS) { + if (remaining <= mark && !fired.has(mark)) { + fired.add(mark); + cues.onCountdown(mark / 1000); + } + } + if (remaining <= 0 && !fired.has(0)) { + fired.add(0); + cues.onRaceEnd(); + } + }; + advance(); + const id = setInterval(advance, TICK_MS); + return () => clearInterval(id); + }); +} diff --git a/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts new file mode 100644 index 0000000..c90b859 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts @@ -0,0 +1,90 @@ +/** + * **New-lap detection** for the live audio callouts — watches the live stream's per-pilot + * `progress` for the CURRENT, RUNNING heat and fires `onLap` once per newly recorded lap. Pure + * detection: the caller decides what a lap sounds like (crossing pip + spoken callout, both + * mute-scoped) — see `LiveRaceControl`. + * + * ── Only genuine live crossings — no ghost callouts ────────────────────────────────────────── + * Fires ONLY on a lap-count *increase* observed on the live stream while the heat is `Running`. + * Everything else stays silent: + * • **not** marshaling corrections / historical folds — those flow through the heat-scoped + * projections (`heatLiveState` / `lapList`), not this live stream; and once a heat leaves + * `Running` this detector is off entirely, so editing a finished heat can never call out. + * (A count *decrease* seen live — a correction folding down — just resyncs the baseline.) + * • **not** non-current heats — the live stream only carries the current heat's progress, and + * a heat swap re-baselines (below) rather than diffing across heats. + * • **not** a late join / re-mount mid-race: the first sight of a run BASELINES the current + * counts silently (mirroring how the start tone suppresses navigation replays), so mounting + * onto lap 7 doesn't narrate laps 1–7. Runs are keyed `heat + race_started_at` — the same run + * anchor the end-of-race tones use — so a Restart's fresh run baselines afresh (at zero). + */ +import type { CompetitorRef, PilotProgress } from '@gridfpv/types'; + +/** One newly recorded lap, as handed to `onLap`. */ +export interface LapCrossing { + /** The competitor ref that crossed (resolve to a callsign via the shared resolver). */ + ref: CompetitorRef; + /** The lap number just completed (the new `laps_completed`). */ + lap: number; + /** The recorded lap duration (µs), when the stream carries it. */ + lastLapMicros: number | undefined; +} + +/** + * Watch the live progress for new laps. All getters are read reactively; `onLap` fires once per + * count-increase per run. Must be called during component setup so its internal `$effect` is + * owned (torn down on unmount). + */ +export function useLapCallouts( + getPhase: () => string | undefined, + getHeat: () => string | undefined, + getStartedAtMicros: () => number | null | undefined, + getProgress: () => readonly PilotProgress[] | undefined, + onLap: (crossing: LapCrossing) => void +): void { + // The per-run lap baseline, OUTSIDE the $effect: stream pushes re-run the effect with the same + // run key, and the seen-counts must survive those re-runs (state inside the effect would + // re-baseline — or worse, re-announce — every push). + let runKey: string | undefined; + let seen = new Map(); + + $effect(() => { + const phase = getPhase(); + const heat = getHeat(); + const progress = getProgress(); + if (phase !== 'Running' || heat === undefined) { + // Off while not Running (a finished heat being marshaled can never call out). Drop the run + // so the next Running run baselines afresh. + runKey = undefined; + seen = new Map(); + return; + } + // The run anchor: heat + server race-go instant (a Restart mints a new one). `race_started_at` + // can lag the Running flip by a tick — key that brief window as 'pending' and re-baseline once + // the anchor lands (counts are still ~0 that early, so nothing is lost). + const startedAt = getStartedAtMicros(); + const key = `${heat}@${startedAt ?? 'pending'}`; + if (key !== runKey) { + // First sight of this run (fresh race-go: all zeros; late join / re-mount: the current + // counts). Baseline SILENTLY — only increases observed from here on call out. + runKey = key; + seen = new Map((progress ?? []).map((p) => [p.competitor, p.laps_completed])); + return; + } + for (const p of progress ?? []) { + const prev = seen.get(p.competitor) ?? 0; + if (p.laps_completed > prev) { + seen.set(p.competitor, p.laps_completed); + onLap({ + ref: p.competitor, + lap: p.laps_completed, + lastLapMicros: p.last_lap_micros ?? undefined + }); + } else if (p.laps_completed < prev) { + // A live correction folded the count DOWN: resync silently (never a callout), so the next + // genuine crossing announces the right lap number. + seen.set(p.competitor, p.laps_completed); + } + } + }); +} diff --git a/frontend/apps/rd-console/src/lib/raceAudio.ts b/frontend/apps/rd-console/src/lib/raceAudio.ts new file mode 100644 index 0000000..1b1a84f --- /dev/null +++ b/frontend/apps/rd-console/src/lib/raceAudio.ts @@ -0,0 +1,288 @@ +/** + * The console's **race-day audio player** (grown out of the heat-lifecycle Slice 3 start tone) — + * every Web-Audio cue the RD console emits, in two clearly-scoped layers: + * + * ── Procedure tones — ALWAYS ON, never muted ───────────────────────────────────────────────── + * The audible race procedure. These are the on-course signals a race runs by, so they play + * unconditionally — there is **no** toggle for them (the old "Tone on/off" switch was a testing + * convenience and is gone): + * • {@link RaceAudioPlayer.playStartTone} — race-go (`Armed → Running`): an 880Hz square burst, + * ~800ms (long enough to read as the "go" over field noise). Pitch/length still honour the + * round's `StartProcedure.tone` cue when configured. + * • {@link RaceAudioPlayer.playCountdownBeep} — the end-of-race countdown pips at remaining + * 5…1s: same pitch family as the start (880Hz), short (~150ms). + * • {@link RaceAudioPlayer.playRaceEndTone} — remaining 0: the race-end buzzer — LOWER (half + * the countdown pitch, 440Hz) and longer (~600ms). It marks the **window** end; grace-window + * laps still count after it (scoring is untouched). + * + * ── Informational layer — mute-scoped ("Callouts") ─────────────────────────────────────────── + * The per-crossing chatter. Useful, but optional — the RD can silence it without losing the + * procedure tones: + * • {@link RaceAudioPlayer.playCrossingBeep} — a very short, higher pip (1760Hz, ~60ms) per + * recorded lap, distinct from the procedure family. + * The spoken lap callouts (Web Speech) live in `callouts.ts` and are gated by the same + * {@link RaceAudioPlayer.muted} preference at the call site. The preference persists to + * `localStorage` under a **new** key ({@link MUTE_STORAGE_KEY}) — the old start-tone mute pref is + * deliberately ignored, since its meaning changed (it used to silence the procedure tones). + * + * ── Autoplay policy ────────────────────────────────────────────────────────────────────────── + * Browsers suspend a freshly-created `AudioContext` until a user gesture. The player lazily + * creates the context on first use and {@link RaceAudioPlayer.resume}s it; the console calls + * `resume()` from the RD's control clicks (Stage/Start), the callouts toggle, **and a one-time + * first-gesture listener** (with the old tone toggle gone, any first pointerdown unlocks audio so + * the procedure tones are never blocked). Play calls on a still-suspended context resume first and + * schedule against the *resumed* clock (the suspended-clock fix). If the context can't be created + * (no Web Audio) every call is a silent no-op — audio never blocks the UI. + * + * ── Testability ────────────────────────────────────────────────────────────────────────────── + * The `AudioContext` constructor is **injected** ({@link RaceAudioPlayerOptions.audioContextFactory}) + * — jsdom has no Web Audio — so a unit test passes a mock and asserts which tones fired, at what + * pitch/length, and that mute gates exactly the informational layer. + */ + +/** + * The persisted callouts-mute preference key (localStorage). Default unset ⇒ unmuted (callouts + * on). Deliberately NOT the old `gridfpv.startTone.muted` key: that pref silenced the *procedure* + * tones, which are now always-on — carrying it over would silently mute the new callouts layer for + * anyone who once muted the test tone. + */ +const MUTE_STORAGE_KEY = 'gridfpv.callouts.muted'; + +/** Default race-go tone when the round carries no `StartProcedure.tone` — a confident 880Hz "go". + * 800ms (doubled from the original 400ms) so it carries over field noise as a proper start horn. */ +const START_HZ = 880; +const START_MS = 800; +/** The end-of-race countdown pips (remaining 5…1s): the start-tone pitch family, short. */ +const COUNTDOWN_HZ = 880; +const COUNTDOWN_MS = 150; +/** The race-end buzzer (remaining 0): half the countdown pitch, longer — unmistakably "time". */ +const RACE_END_HZ = COUNTDOWN_HZ / 2; +const RACE_END_MS = 600; +/** The per-lap crossing pip (informational, mute-scoped): higher + very short, a distinct voice. */ +const CROSSING_HZ = 1760; +const CROSSING_MS = 60; + +/** The minimal `AudioContext` surface the player drives (so a test mock implements just this). */ +export interface ToneAudioContext { + readonly currentTime: number; + readonly state: string; + createOscillator(): { + type: OscillatorType; + frequency: { setValueAtTime(value: number, atTime: number): void }; + connect(destination: unknown): void; + start(when?: number): void; + stop(when?: number): void; + }; + createGain(): { + gain: { + setValueAtTime(value: number, atTime: number): void; + linearRampToValueAtTime(value: number, atTime: number): void; + }; + connect(destination: unknown): void; + }; + readonly destination: unknown; + resume(): Promise; + close(): Promise; +} + +/** The start-tone cue — the round's `StartProcedure.tone`, both fields optional (defaults fill). */ +export interface ToneCue { + hz?: number; + ms?: number; +} + +export interface RaceAudioPlayerOptions { + /** + * Inject the `AudioContext` constructor (defaults to the platform one). A test passes a mock so + * the tones are observable without real audio; production omits it. Returning a context that + * throws on construction is handled as "no audio" (silent no-op). + */ + audioContextFactory?: () => ToneAudioContext; + /** Start with callouts muted regardless of storage (tests). Otherwise the persisted pref reads. */ + initialMuted?: boolean; +} + +/** Read the platform `AudioContext` constructor, or `undefined` where Web Audio is unavailable. */ +function platformAudioContext(): (() => ToneAudioContext) | undefined { + const Ctor = + (globalThis as unknown as { AudioContext?: new () => ToneAudioContext }).AudioContext ?? + (globalThis as unknown as { webkitAudioContext?: new () => ToneAudioContext }) + .webkitAudioContext; + if (!Ctor) return undefined; + return () => new Ctor(); +} + +function loadMuted(): boolean { + try { + return globalThis.localStorage?.getItem(MUTE_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function persistMuted(muted: boolean): void { + try { + globalThis.localStorage?.setItem(MUTE_STORAGE_KEY, muted ? 'true' : 'false'); + } catch { + /* storage unavailable — in-memory still works */ + } +} + +/** + * The race-day audio player. One instance per console (created in the live screen). Holds the + * lazily-created `AudioContext`, the persisted callouts-mute state, and the tone methods above. + */ +export class RaceAudioPlayer { + /** + * The RD's callouts-mute preference — scoped to the **informational layer only** (the crossing + * pip here + the spoken callouts at the call site). Procedure tones ignore it. A plain + * (non-rune) field so this player is unit-testable as a pure class outside a component; the live + * screen mirrors it into its own `$state` for the toolbar toggle's reactivity. + */ + #muted = false; + #factory: (() => ToneAudioContext) | undefined; + #ctx: ToneAudioContext | undefined; + + constructor(opts?: RaceAudioPlayerOptions) { + this.#factory = opts?.audioContextFactory ?? platformAudioContext(); + this.#muted = opts?.initialMuted ?? loadMuted(); + } + + /** Whether the informational layer (crossing pips + callouts) is currently muted. */ + get muted(): boolean { + return this.#muted; + } + + /** Whether this environment can play a tone at all (Web Audio is available). */ + get available(): boolean { + return this.#factory !== undefined; + } + + /** Set the callouts-mute preference and persist it. */ + setMuted(muted: boolean): void { + this.#muted = muted; + persistMuted(muted); + } + + /** Flip the callouts-mute preference and persist it; returns the new state. */ + toggleMuted(): boolean { + this.setMuted(!this.#muted); + return this.#muted; + } + + /** Lazily create the `AudioContext` (once), or `undefined` if unavailable / construction fails. */ + #context(): ToneAudioContext | undefined { + if (this.#ctx) return this.#ctx; + if (!this.#factory) return undefined; + try { + this.#ctx = this.#factory(); + } catch { + this.#factory = undefined; // don't retry a broken constructor + this.#ctx = undefined; + } + return this.#ctx; + } + + /** + * Unlock the audio context on a user gesture (autoplay policy). Safe to call repeatedly; resumes + * a suspended context so a later procedure tone is audible. A no-op where audio is unavailable. + */ + async resume(): Promise { + const ctx = this.#context(); + if (!ctx) return; + try { + if (ctx.state !== 'running') await ctx.resume(); + } catch { + /* resume can reject before a gesture — the next gesture retries */ + } + } + + /** + * The race-go tone — a **procedure tone**, plays unconditionally (never muted). The cue's + * `hz`/`ms` fall back to the {@link START_HZ}/{@link START_MS} defaults. Never throws. + */ + playStartTone(cue?: ToneCue): void { + this.#play(cue?.hz ?? START_HZ, cue?.ms ?? START_MS, false); + } + + /** One end-of-race countdown pip (remaining 5…1s) — a **procedure tone**, never muted. */ + playCountdownBeep(): void { + this.#play(COUNTDOWN_HZ, COUNTDOWN_MS, false); + } + + /** The race-end buzzer (remaining 0; lower + longer) — a **procedure tone**, never muted. */ + playRaceEndTone(): void { + this.#play(RACE_END_HZ, RACE_END_MS, false); + } + + /** The per-lap crossing pip — **informational**, silenced by the callouts mute. */ + playCrossingBeep(): void { + this.#play(CROSSING_HZ, CROSSING_MS, true); + } + + /** + * Play one tone now. No Web Audio ⇒ no-op; `gatedByMute` tones are dropped while muted. Never + * throws. + * + * ── Why this resumes-then-schedules (the audible-tone fix) ────────────────────────────────── + * Race-go (`Armed → Running`) is an **auto** transition the runtime drives — there's no click on + * that edge — so the `AudioContext` may still be **suspended** by the browser autoplay policy. A + * suspended context has a **frozen** `currentTime`; scheduling a note against that frozen clock + * and only resuming afterwards means the note's start time is already in the past when audio + * actually begins, so it never sounds (the original "no tone" bug). We therefore **`resume()` + * first and schedule the note from the resumed clock**. The console also unlocks on the earlier + * Start gesture / first pointerdown ({@link resume}) so this path is usually instant. + */ + #play(hz: number, ms: number, gatedByMute: boolean): void { + if (gatedByMute && this.#muted) return; + const ctx = this.#context(); + if (!ctx) return; + if (ctx.state === 'running') { + this.#emit(ctx, hz, ms); + return; + } + // Suspended (autoplay policy): resume, then schedule against the *resumed* clock. A mute-gated + // tone re-checks the mute at fire time in case it was toggled during the (brief) resume. + void ctx + .resume() + .then(() => { + if (!gatedByMute || !this.#muted) this.#emit(ctx, hz, ms); + }) + .catch(() => { + /* still locked (no gesture yet) — nothing to play; the next gesture unlocks it */ + }); + } + + /** Build and fire the oscillator → gain → destination burst on a running context. Never throws. */ + #emit(ctx: ToneAudioContext, hz: number, ms: number): void { + try { + const now = ctx.currentTime; + const dur = Math.max(0.05, ms / 1000); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(hz, now); + // A short attack + release so the burst reads as a clean beep, not a click. The audible + // sustain level is 0.25 (non-zero — the bug would be a zero envelope or a missing connect). + gain.gain.setValueAtTime(0.0001, now); + gain.gain.linearRampToValueAtTime(0.25, now + 0.01); + gain.gain.linearRampToValueAtTime(0.0001, now + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + dur + 0.02); + } catch { + /* a flaky audio backend must never break the live screen */ + } + } + + /** Tear down the audio context (on unmount). Idempotent; never throws. */ + dispose(): void { + try { + void this.#ctx?.close(); + } catch { + /* ignore */ + } + this.#ctx = undefined; + } +} diff --git a/frontend/apps/rd-console/src/lib/startTone.ts b/frontend/apps/rd-console/src/lib/startTone.ts deleted file mode 100644 index 0b19de7..0000000 --- a/frontend/apps/rd-console/src/lib/startTone.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * The **start tone** (heat-lifecycle Slice 3) — the console's audible race-go cue, played the - * moment a heat crosses `Armed → Running` so the RD (and anyone near the console) hears the start - * synced to the race actually going live. - * - * It's a short oscillator burst over the **Web Audio API**: an `OscillatorNode` through a - * `GainNode` with a tiny attack/release envelope so it reads as a clean beep, not a click. The - * pitch/length come from the round's `StartProcedure.tone` config when present, else a sensible - * default (an `880`Hz, `400`ms tone — a confident "go"). - * - * ── Mute toggle (persisted) ────────────────────────────────────────────────────────────────── - * The RD can mute the tone; the choice persists to `localStorage` (default **on** — a start cue is - * wanted by default). {@link StartTonePlayer.muted} is a reactive getter so the toolbar toggle and - * the player agree. - * - * ── Autoplay policy ────────────────────────────────────────────────────────────────────────── - * Browsers suspend a freshly-created `AudioContext` until a user gesture. The player lazily creates - * the context on first use and {@link StartTonePlayer.resume}s it; the console calls `resume()` from - * the RD's control clicks (Stage/Start) and the mute toggle so the context is unlocked well before - * race-go. {@link StartTonePlayer.play} also resumes a still-suspended context before scheduling the - * note (the suspended-clock fix). If the context can't be created (no Web Audio) every call is a - * silent no-op — the tone never blocks the UI. - * - * ── Testability ────────────────────────────────────────────────────────────────────────────── - * The `AudioContext` constructor is **injected** ({@link StartTonePlayerOptions.audioContextFactory}), - * so a unit test passes a mock and asserts an oscillator was started at race-go, that mute suppresses - * it, and that the configured/default frequency is used — with no real audio hardware. - */ - -/** The persisted mute-preference key (localStorage). Default unset ⇒ unmuted (tone on). */ -const MUTE_STORAGE_KEY = 'gridfpv.startTone.muted'; - -/** Default tone when the round carries no `StartProcedure.tone` — a confident 880Hz "go". */ -const DEFAULT_HZ = 880; -const DEFAULT_MS = 400; - -/** The minimal `AudioContext` surface the player drives (so a test mock implements just this). */ -export interface ToneAudioContext { - readonly currentTime: number; - readonly state: string; - createOscillator(): { - type: OscillatorType; - frequency: { setValueAtTime(value: number, atTime: number): void }; - connect(destination: unknown): void; - start(when?: number): void; - stop(when?: number): void; - }; - createGain(): { - gain: { - setValueAtTime(value: number, atTime: number): void; - linearRampToValueAtTime(value: number, atTime: number): void; - }; - connect(destination: unknown): void; - }; - readonly destination: unknown; - resume(): Promise; - close(): Promise; -} - -/** The tone cue to play — the round's `StartProcedure.tone`, both fields optional (defaults fill). */ -export interface ToneCue { - hz?: number; - ms?: number; -} - -export interface StartTonePlayerOptions { - /** - * Inject the `AudioContext` constructor (defaults to the platform one). A test passes a mock so - * `play()` is observable without real audio; production omits it. Returning a context that throws - * on construction is handled as "no audio" (silent no-op). - */ - audioContextFactory?: () => ToneAudioContext; - /** Start muted regardless of storage (tests). Otherwise the persisted preference is read. */ - initialMuted?: boolean; -} - -/** Read the platform `AudioContext` constructor, or `undefined` where Web Audio is unavailable. */ -function platformAudioContext(): (() => ToneAudioContext) | undefined { - const Ctor = - (globalThis as unknown as { AudioContext?: new () => ToneAudioContext }).AudioContext ?? - (globalThis as unknown as { webkitAudioContext?: new () => ToneAudioContext }) - .webkitAudioContext; - if (!Ctor) return undefined; - return () => new Ctor(); -} - -function loadMuted(): boolean { - try { - return globalThis.localStorage?.getItem(MUTE_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function persistMuted(muted: boolean): void { - try { - globalThis.localStorage?.setItem(MUTE_STORAGE_KEY, muted ? 'true' : 'false'); - } catch { - /* storage unavailable — in-memory still works */ - } -} - -/** - * The reactive start-tone player. One instance per console (created in the live screen). Holds the - * lazily-created `AudioContext`, the persisted mute state, and the `play(cue)` that fires the beep. - */ -export class StartTonePlayer { - /** - * The RD's mute preference. A plain (non-rune) field so this player is unit-testable as a pure - * class outside a component; the live screen mirrors it into its own `$state` for the toolbar - * toggle's reactivity (re-reading {@link muted} after {@link toggleMuted}). - */ - #muted = false; - #factory: (() => ToneAudioContext) | undefined; - #ctx: ToneAudioContext | undefined; - - constructor(opts?: StartTonePlayerOptions) { - this.#factory = opts?.audioContextFactory ?? platformAudioContext(); - this.#muted = opts?.initialMuted ?? loadMuted(); - } - - /** Whether the tone is currently muted. */ - get muted(): boolean { - return this.#muted; - } - - /** Whether this environment can play a tone at all (Web Audio is available). */ - get available(): boolean { - return this.#factory !== undefined; - } - - /** Set the mute preference and persist it. */ - setMuted(muted: boolean): void { - this.#muted = muted; - persistMuted(muted); - } - - /** Flip the mute preference and persist it; returns the new state. */ - toggleMuted(): boolean { - this.setMuted(!this.#muted); - return this.#muted; - } - - /** Lazily create the `AudioContext` (once), or `undefined` if unavailable / construction fails. */ - #context(): ToneAudioContext | undefined { - if (this.#ctx) return this.#ctx; - if (!this.#factory) return undefined; - try { - this.#ctx = this.#factory(); - } catch { - this.#factory = undefined; // don't retry a broken constructor - this.#ctx = undefined; - } - return this.#ctx; - } - - /** - * Unlock the audio context on a user gesture (autoplay policy). Safe to call repeatedly; resumes - * a suspended context so the later race-go `play()` is audible. A no-op where audio is unavailable. - */ - async resume(): Promise { - const ctx = this.#context(); - if (!ctx) return; - try { - if (ctx.state !== 'running') await ctx.resume(); - } catch { - /* resume can reject before a gesture — the next gesture retries */ - } - } - - /** - * Play the start tone now. Muted ⇒ no-op; no Web Audio ⇒ no-op. The cue's `hz`/`ms` fall back to - * the {@link DEFAULT_HZ}/{@link DEFAULT_MS} defaults. Never throws. - * - * ── Why this resumes-then-schedules (the audible-tone fix) ────────────────────────────────── - * Race-go (`Armed → Running`) is an **auto** transition the runtime drives — there's no click on - * that edge — so the `AudioContext` may still be **suspended** by the browser autoplay policy. A - * suspended context has a **frozen** `currentTime`; scheduling a note against that frozen clock and - * only resuming afterwards means the note's start time is already in the past when audio actually - * begins, so it never sounds (the original "no tone" bug). We therefore **`resume()` first and - * schedule the note from the resumed clock**. The console also unlocks on the earlier `Start` - * gesture ({@link resume}) so the context is usually already running and this path is instant. - */ - play(cue?: ToneCue): void { - if (this.#muted) return; - const ctx = this.#context(); - if (!ctx) return; - if (ctx.state === 'running') { - this.#emit(ctx, cue); - return; - } - // Suspended (autoplay policy): resume, then schedule against the *resumed* clock. Re-check mute - // at fire time in case it was toggled during the (brief) resume. - void ctx - .resume() - .then(() => { - if (!this.#muted) this.#emit(ctx, cue); - }) - .catch(() => { - /* still locked (no gesture yet) — nothing to play; the next gesture unlocks it */ - }); - } - - /** Build and fire the oscillator → gain → destination burst on a running context. Never throws. */ - #emit(ctx: ToneAudioContext, cue?: ToneCue): void { - const hz = cue?.hz ?? DEFAULT_HZ; - const ms = cue?.ms ?? DEFAULT_MS; - try { - const now = ctx.currentTime; - const dur = Math.max(0.05, ms / 1000); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = 'square'; - osc.frequency.setValueAtTime(hz, now); - // A short attack + release so the burst reads as a clean beep, not a click. The audible - // sustain level is 0.25 (non-zero — the bug would be a zero envelope or a missing connect). - gain.gain.setValueAtTime(0.0001, now); - gain.gain.linearRampToValueAtTime(0.25, now + 0.01); - gain.gain.linearRampToValueAtTime(0.0001, now + dur); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(now); - osc.stop(now + dur + 0.02); - } catch { - /* a flaky audio backend must never break the live screen */ - } - } - - /** Tear down the audio context (on unmount). Idempotent; never throws. */ - dispose(): void { - try { - void this.#ctx?.close(); - } catch { - /* ignore */ - } - this.#ctx = undefined; - } -} diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index f98eecb..733265f 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -48,7 +48,10 @@ import { useStagingClock, formatStaging } from '../lib/stagingClock.svelte.js'; import { useProtestClock, formatProtest } from '../lib/protestClock.svelte.js'; import { useArmingClock, formatArming } from '../lib/armingClock.svelte.js'; - import { StartTonePlayer } from '../lib/startTone.js'; + import { RaceAudioPlayer } from '../lib/raceAudio.js'; + import { CalloutQueue, lapCalloutTexts } from '../lib/callouts.js'; + import { useEndOfRaceTones } from '../lib/endTones.svelte.js'; + import { useLapCallouts } from '../lib/lapCallouts.svelte.js'; import ConfirmButton from '../lib/ConfirmButton.svelte'; import ErrorBanner from '../lib/ErrorBanner.svelte'; @@ -400,8 +403,29 @@ () => session.serverNowMs() ); + // ── Race-day audio (procedure tones + informational callouts) ───────────────────────────────── + // One RaceAudioPlayer (Web-Audio tones) + one CalloutQueue (spoken lap callouts) per console. + // PROCEDURE tones — start tone, end-of-race countdown, race-end buzzer — are ALWAYS ON (the old + // "Tone on/off" toggle is gone; it was a testing convenience). The INFORMATIONAL layer — crossing + // pips + spoken callouts — is what the "Callouts" toggle mutes. + const audio = new RaceAudioPlayer(); + const callouts = new CalloutQueue(); + $effect(() => () => { + callouts.cancel(); + audio.dispose(); + }); + + // Autoplay unlock: with no tone toggle to click first, the RD's FIRST gesture anywhere on the + // page resumes the AudioContext (in addition to the Stage/Start handlers below), so the always-on + // procedure tones are never blocked by the browser autoplay policy. + $effect(() => { + const unlock = () => void audio.resume(); + document.addEventListener('pointerdown', unlock, { once: true }); + return () => document.removeEventListener('pointerdown', unlock); + }); + // ── Start tone synced to race-go (heat-lifecycle Slice 3; robustness + late-join fix) ────────── - // A short Web-Audio beep the moment a heat goes live (race-go). The runtime logs + // A Web-Audio burst the moment a heat goes live (race-go). The runtime logs // `HeatStarting { delay_ms }` then auto-appends the Running transition after the (hidden) random // hold; the console plays the tone when the *live phase* turns Running. // @@ -418,8 +442,6 @@ // Per heat we track two things, both reset on a heat change: whether a pre-Running phase was seen // (`tonePreRunningForHeat`), and whether the tone already fired (`toneFiredForHeat`, so repeated // Running snapshots / progress updates don't re-fire). The next heat resets and fires its own. - const tone = new StartTonePlayer(); - $effect(() => () => tone.dispose()); let toneFiredForHeat = $state(undefined); let tonePreRunningForHeat = $state(undefined); $effect(() => { @@ -440,15 +462,82 @@ // load onto an in-progress heat). if (toneFiredForHeat !== h && tonePreRunningForHeat === h) { toneFiredForHeat = h; - tone.play(toneCue); + audio.playStartTone(toneCue); } }); - let muted = $state(tone.muted); + + // ── The known fixed race end (end-of-race tones) ─────────────────────────────────────────────── + // Only a heat whose end instant the clock already knows gets a countdown: a Timed win-condition + // round (window measured from race-go), or a Practice run with a time limit. First-to-N / BestLap + // rounds have no fixed end → no countdown. NOTE an open-practice round stores an inert *default* + // win condition that is never consulted (see RoundDef) — only its `time_limit_secs` bounds the + // run, so a limitless practice gets no countdown either. + const fixedEndWindowMicros = $derived.by(() => { + const round = currentRound; + if (!round) return undefined; + if (round.format === OPEN_PRACTICE) { + return round.time_limit_secs != null ? round.time_limit_secs * 1_000_000 : undefined; + } + const wc = round.win_condition; + if (typeof wc === 'object' && wc !== null && 'Timed' in wc) return wc.Timed.window_micros; + return undefined; + }); + + // ── End-of-race countdown + buzzer (procedure tones — always on) ─────────────────────────────── + // At remaining 5…1s a short pip each second; at 0 the lower, longer race-end buzzer. The schedule + // derives from the SAME server-anchored clock the heat-time readout uses (`serverNowMs` against + // `race_started_at`) and fires each mark once per heat run — see endTones.svelte.ts. The buzzer + // marks the WINDOW end; grace-window laps still count after it (scoring untouched). + useEndOfRaceTones( + () => phase, + () => heat, + () => live?.race_started_at, + () => fixedEndWindowMicros, + () => session.serverNowMs(), + { + onCountdown: () => audio.playCountdownBeep(), + onRaceEnd: () => audio.playRaceEndTone() + } + ); + + // ── Lap callouts (informational layer — the "Callouts" toggle mutes these) ───────────────────── + // Each NEW lap on the current Running heat: an immediate crossing pip, then a queued spoken + // ", lap N, M.S". Detection (once per count-increase per run, no ghost callouts from + // late joins / corrections / non-current heats) lives in lapCallouts.svelte.ts; the callsign + // resolves through the shared competitor-name resolver (friendly-names rule) — when even the + // resolver falls back to the raw ref, the callout skips the name rather than speaking an id. + useLapCallouts( + () => phase, + () => heat, + () => live?.race_started_at, + () => live?.progress, + (crossing) => { + if (audio.muted) return; // the informational layer is mute-scoped + audio.playCrossingBeep(); + const name = competitorName(crossing.ref); + callouts.enqueue( + lapCalloutTexts( + name === crossing.ref ? undefined : name, + crossing.lap, + crossing.lastLapMicros + ) + ); + } + ); + // When the heat run ends (any non-Running fold: finished, aborted, discarded), drop whatever is + // still queued — narrating a race that's over is noise on top of the next heat's staging. + $effect(() => { + if (phase !== 'Running') callouts.cancel(); + }); + + let muted = $state(audio.muted); function toggleMute() { // The toggle is itself a user gesture — unlock the audio context here too so an RD who only - // ever touches the mute button (never a transition) still gets an audible race-go tone. - void tone.resume(); - muted = tone.toggleMuted(); + // ever touches this button (never a transition) still gets audible tones. + void audio.resume(); + muted = audio.toggleMuted(); + // Muting mid-race also drops the queued speech — silence means silence, immediately. + if (muted) callouts.cancel(); } // A live, provisional leaderboard from the running order + per-pilot progress, so the @@ -483,7 +572,7 @@ if (!heat) return; // Unlock the audio context on this user gesture (autoplay policy) so the later race-go tone is // audible — every transition click counts, well before the heat reaches Running. - void tone.resume(); + void audio.resume(); const ack = await session.send(commandForAction(action, heat)); // Finalizing locks in the heat result; pull it so the Results screen has it to show. The // live stream only carries `LiveRaceState`, so the scored `HeatResult` is a separate @@ -554,16 +643,21 @@
    {/if} +
    diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index bba6a21..d2a6edb 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -52,6 +52,86 @@ const HEAT_IN_ROUND: HeatSummary = { const liveAt = (phase: LiveRaceState['phase'], heat: string | undefined = 'heat-1') => ({ current_heat: heat, phase }) as LiveRaceState; +// A stub platform AudioContext the screen's RaceAudioPlayer picks up, recording each started +// oscillator's FREQUENCY (so a test can tell the start tone 880 / countdown pip 880 / race-end +// buzzer 440 / crossing pip 1760 apart) plus resume()/createOscillator counts — no real audio. +// `calloutsMuted` seeds the persisted callouts-mute pref (the informational-layer toggle). +function installAudioStub(state = 'running', opts?: { calloutsMuted?: boolean }) { + const started: number[] = []; + let resumes = 0; + let oscillators = 0; + class MockAudioContext { + currentTime = 0; + state = state; + destination = {}; + createOscillator() { + oscillators++; + let freq = 0; + return { + type: 'square', + frequency: { + setValueAtTime(value: number) { + freq = value; + } + }, + connect() {}, + start() { + started.push(freq); + }, + stop() {} + }; + } + createGain() { + return { + gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, + connect() {} + }; + } + async resume() { + resumes++; + this.state = 'running'; + } + async close() {} + } + vi.stubGlobal('AudioContext', MockAudioContext); + // Seed the callouts-mute pref: unmuted by default; 'true' asserts the mute SCOPE (informational + // layer only — the procedure tones must ignore it). + vi.stubGlobal('localStorage', { + getItem: (key: string) => + key === 'gridfpv.callouts.muted' && opts?.calloutsMuted ? 'true' : null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + key: () => null, + length: 0 + } as unknown as Storage); + return { + started, + calls: () => ({ resumes, oscillators }) + }; +} + +// A stub Web Speech API (jsdom has neither speechSynthesis nor SpeechSynthesisUtterance): records +// every spoken utterance so the callout texts + the cancel-on-heat-end path are observable. +function installSpeechStub() { + const utterances: Array<{ text: string; onend: (() => void) | null }> = []; + const cancelSpy = vi.fn(); + vi.stubGlobal('speechSynthesis', { + speak: (u: { text: string; onend: (() => void) | null }) => utterances.push(u), + cancel: cancelSpy + }); + class MockUtterance { + text: string; + onend: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor(text: string) { + this.text = text; + } + } + vi.stubGlobal('SpeechSynthesisUtterance', MockUtterance); + return { utterances, cancelSpy }; +} + describe('LiveRaceControl', () => { it('enables only the phase-legal transitions (Running → ForceEnd/Abort/Restart)', () => { const { session } = makeTestSession({ live: liveRunning }); @@ -353,56 +433,6 @@ describe('LiveRaceControl', () => { expect(arming).not.toHaveTextContent(/Tone in/); }); - // A stub platform AudioContext the screen's StartTonePlayer picks up, recording oscillator - // starts + resume()/createOscillator calls so we can assert the tone path with no real audio. - function installAudioStub(state = 'running') { - const started: number[] = []; - let resumes = 0; - let oscillators = 0; - class MockAudioContext { - currentTime = 0; - state = state; - destination = {}; - createOscillator() { - oscillators++; - return { - type: 'square', - frequency: { setValueAtTime() {} }, - connect() {}, - start() { - started.push(1); - }, - stop() {} - }; - } - createGain() { - return { - gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, - connect() {} - }; - } - async resume() { - resumes++; - this.state = 'running'; - } - async close() {} - } - vi.stubGlobal('AudioContext', MockAudioContext); - // Ensure the mute pref reads unmuted regardless of any leaked storage. - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - key: () => null, - length: 0 - } as unknown as Storage); - return { - started, - calls: () => ({ resumes, oscillators }) - }; - } - it('plays the start tone when the heat enters Running (from Armed)', async () => { const { started } = installAudioStub('running'); @@ -520,19 +550,232 @@ describe('LiveRaceControl', () => { vi.unstubAllGlobals(); }); - it('does not render an inline Enable/Test-tone button (removed)', async () => { + it('the old Tone toggle is GONE; the audio toolbar holds only the renamed Callouts toggle', async () => { installAudioStub('suspended'); const { session } = makeTestSession({ live: liveAt('Staged') }); render(LiveRaceControl, { session }); await tick(); - // The test-tone affordance is gone; only the mute toggle remains in the audio toolbar. + // The test-tone affordance and the procedure-tone mute are gone; the one remaining audio + // control is the informational-layer "Callouts" toggle. expect(screen.queryByRole('button', { name: /Enable sound/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Test tone/ })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Tone on|Tone off/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Tone on|Tone off/ })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Callouts on/ })).toBeInTheDocument(); + + vi.unstubAllGlobals(); + }); + + it('plays the start tone EVEN WHILE the callouts are muted (procedure tones are always-on)', async () => { + const { started } = installAudioStub('running', { calloutsMuted: true }); + + const { session, pushLive } = makeTestSession({ live: liveAt('Staged') }); + render(LiveRaceControl, { session }); + await tick(); + // The toggle reads muted — and the race-go tone still fires. + expect(screen.getByRole('button', { name: /Callouts off/ })).toBeInTheDocument(); + pushLive(liveAt('Running')); + await tick(); + expect(started).toEqual([880]); + + vi.unstubAllGlobals(); + }); + }); + describe('end-of-race countdown + buzzer (Timed heats only)', () => { + afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); + + // A Running live state carrying the server race-go anchor (µs). The ROUND above is Timed with + // a 120s window, so the fixed end is race_started_at + 120s. + const runningAt = (startedAtMicros: number): LiveRaceState => + ({ ...liveAt('Running'), race_started_at: startedAtMicros }) as LiveRaceState; + + it('pips at remaining 5..1s and fires the LOWER race-end buzzer at 0 — once each', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { started } = installAudioStub('running'); + const { session, pushLive } = makeTestSession({ + event: EVENT_WITH_ROUND, + live: liveAt('Staged'), + listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) + }); + render(LiveRaceControl, { session }); + // Let the heats/round directory settle so the Timed window resolves. + await vi.advanceTimersByTimeAsync(0); + await tick(); + + // Race-go at t=0 (server anchor 0µs): the start tone fires; the countdown is far off. + pushLive(runningAt(0)); + await tick(); + expect(started).toEqual([880]); + + // …114s in (remaining 6s): still quiet. + await vi.advanceTimersByTimeAsync(114_000); + expect(started).toEqual([880]); + + // Remaining 5s → the first pip (start-tone pitch family). + await vi.advanceTimersByTimeAsync(1_000); + expect(started).toEqual([880, 880]); + + // 4,3,2,1 land each second on the way to remaining 1s. + await vi.advanceTimersByTimeAsync(4_000); + expect(started).toEqual([880, 880, 880, 880, 880, 880]); + + // Remaining 0 → the race-end buzzer: LOWER (440), fired once. + await vi.advanceTimersByTimeAsync(1_000); + expect(started).toEqual([880, 880, 880, 880, 880, 880, 440]); + + // The grace window keeps the heat Running past the buzzer — nothing replays. + await vi.advanceTimersByTimeAsync(5_000); + expect(started).toEqual([880, 880, 880, 880, 880, 880, 440]); + }); + + it('no countdown for a First-to-N heat (no known fixed end)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { started } = installAudioStub('running'); + const lapsRound: RoundDef = { + ...ROUND, + win_condition: { FirstToLaps: { n: 3 } } + }; + const { session, pushLive } = makeTestSession({ + event: { ...EVENT_WITH_ROUND, rounds: [lapsRound] }, + live: liveAt('Staged'), + listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) + }); + render(LiveRaceControl, { session }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + + pushLive(runningAt(0)); + await tick(); + // A long while later: only the start tone ever sounded — no pips, no buzzer. + await vi.advanceTimersByTimeAsync(300_000); + expect(started).toEqual([880]); + }); + + it('the countdown pips are NOT silenced by the callouts mute (procedure tones)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { started } = installAudioStub('running', { calloutsMuted: true }); + const { session, pushLive } = makeTestSession({ + event: EVENT_WITH_ROUND, + live: liveAt('Staged'), + listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) + }); + render(LiveRaceControl, { session }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + + pushLive(runningAt(0)); + await tick(); + await vi.advanceTimersByTimeAsync(120_000); + // Muted callouts — yet start tone + all five pips + the buzzer all sounded. + expect(started).toEqual([880, 880, 880, 880, 880, 880, 440]); + }); + }); + + describe('lap callouts (informational layer — crossing pip + spoken callout)', () => { + afterEach(() => vi.unstubAllGlobals()); + + // A roster-seeded lineup so the callsign resolves through the shared resolver: the competitor + // ref IS the pilot id, looked up in the pilots directory. + const CO_PILOTS = [{ id: 'maverick-4d9rp8', callsign: 'Maverick', vtx_types: [] }]; + const coLive = (laps: number, lastLapMicros?: number, phase = 'Running'): LiveRaceState => + ({ + current_heat: 'heat-1', + phase, + race_started_at: 1_000, + active_pilots: ['maverick-4d9rp8'], + progress: [ + { + competitor: 'maverick-4d9rp8', + laps_completed: laps, + ...(lastLapMicros != null ? { last_lap_micros: lastLapMicros } : {}) + } + ], + running_order: ['maverick-4d9rp8'] + }) as LiveRaceState; + + function renderCallouts(opts?: { calloutsMuted?: boolean }) { + const audioStub = installAudioStub('running', opts); + const speech = installSpeechStub(); + const madeSession = makeTestSession({ + event: EVENT_WITH_ROUND, + live: coLive(0), + listHeatsImpl: vi.fn(async () => [{ ...HEAT_IN_ROUND, lineup: ['maverick-4d9rp8'] }]), + listPilotsImpl: vi.fn(async () => CO_PILOTS as unknown as never) + }); + render(LiveRaceControl, { session: madeSession.session }); + return { ...madeSession, ...audioStub, ...speech }; + } + + it('a new lap fires the crossing pip + speaks ", lap N, M.S" (resolved name)', async () => { + const { pushLive, started, utterances } = renderCallouts(); + // Let the pilots directory settle so the callsign resolves before the crossing. + await waitFor(() => expect(screen.getAllByText('Maverick').length).toBeGreaterThan(0)); + + pushLive(coLive(1, 21_400_000)); + await tick(); + // The crossing pip is the distinct high/short voice (1760), not a procedure tone. + expect(started).toEqual([1760]); + expect(utterances.map((u) => u.text)).toEqual(['Maverick, lap 1, 21.4']); + }); + + it('the callouts mute silences BOTH the crossing pip and the speech', async () => { + const { pushLive, started, utterances } = renderCallouts({ calloutsMuted: true }); + await waitFor(() => expect(screen.getAllByText('Maverick').length).toBeGreaterThan(0)); + + pushLive(coLive(1, 21_400_000)); + await tick(); + expect(started).toEqual([]); + expect(utterances).toEqual([]); + }); + + it('no ghost callouts from a non-Running fold (corrections on a finished heat)', async () => { + const { pushLive, started, utterances } = renderCallouts(); + await waitFor(() => expect(screen.getAllByText('Maverick').length).toBeGreaterThan(0)); + + // The heat finishes; a marshaling-style fold bumps the count on the finished heat. + pushLive(coLive(0, undefined, 'Unofficial')); + await tick(); + pushLive(coLive(1, 20_000_000, 'Unofficial')); + await tick(); + expect(started).toEqual([]); + expect(utterances).toEqual([]); + }); + + it('cancels the queued speech when the heat ends', async () => { + const { pushLive, cancelSpy } = renderCallouts(); + await waitFor(() => expect(screen.getAllByText('Maverick').length).toBeGreaterThan(0)); + + pushLive(coLive(1, 21_400_000)); + await tick(); + pushLive(coLive(0, undefined, 'Unofficial')); + await tick(); + expect(cancelSpy).toHaveBeenCalled(); + }); + + it('muting mid-race cancels the queued speech immediately', async () => { + const { pushLive, cancelSpy, utterances } = renderCallouts(); + await waitFor(() => expect(screen.getAllByText('Maverick').length).toBeGreaterThan(0)); + + pushLive(coLive(1, 21_400_000)); + await tick(); + expect(utterances).toHaveLength(1); + + await fireEvent.click(screen.getByRole('button', { name: /Callouts on/ })); + expect(cancelSpy).toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /Callouts off/ })).toBeInTheDocument(); + + // Further crossings while muted stay silent. + pushLive(coLive(2, 20_000_000)); + await tick(); + expect(utterances).toHaveLength(1); + }); }); describe('race clock (#62)', () => { diff --git a/frontend/apps/rd-console/tests/callouts.test.ts b/frontend/apps/rd-console/tests/callouts.test.ts new file mode 100644 index 0000000..465cb02 --- /dev/null +++ b/frontend/apps/rd-console/tests/callouts.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for the spoken lap-callout queue + text builder. jsdom has no + * `speechSynthesis`/`SpeechSynthesisUtterance`, so a **fake speech seam** is injected; the tests + * drive its `onend` to walk the queue and assert: + * • serialization — one utterance at a time, FIFO; + * • coalescing — a backlog deeper than 3 falls back to the short (", lap N") form; + * • cancel — drops the backlog, stops the synth, and a stale in-flight `onend` cannot restart + * a second speaking chain; + * • the spoken texts — resolved callsign + lap + seconds, name-skipped when unresolved. + */ +import { describe, expect, it, vi } from 'vitest'; +import { + CalloutQueue, + formatLapSeconds, + lapCalloutTexts, + type CalloutSpeech, + type SpeechUtteranceLike +} from '../src/lib/callouts.js'; + +/** A fake speech seam recording spoken texts; `finish()` fires the current utterance's onend. */ +function makeFakeSpeech() { + const spoken: string[] = []; + const pending: SpeechUtteranceLike[] = []; + const cancelSpy = vi.fn(); + const speech: CalloutSpeech = { + synth: { + speak(u) { + spoken.push(u.text); + pending.push(u); + }, + cancel: cancelSpy + }, + makeUtterance: (text) => ({ text, onend: null, onerror: null }) + }; + /** Complete the oldest still-speaking utterance (fires its onend). */ + const finish = () => pending.shift()?.onend?.(); + return { speech, spoken, finish, cancelSpy, pending }; +} + +describe('CalloutQueue', () => { + it('speaks FIFO, one utterance at a time (the next only after the current ends)', () => { + const { speech, spoken, finish } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + + q.enqueue({ full: 'Maverick, lap 1, 21.4', short: 'Maverick, lap 1' }); + q.enqueue({ full: 'Goose, lap 1, 22.0', short: 'Goose, lap 1' }); + + // Only the first has been handed to the synth; the second waits its turn. + expect(spoken).toEqual(['Maverick, lap 1, 21.4']); + finish(); + expect(spoken).toEqual(['Maverick, lap 1, 21.4', 'Goose, lap 1, 22.0']); + finish(); + expect(spoken).toHaveLength(2); + }); + + it('coalesces to the short form once the backlog runs deeper than 3 (pack finish)', () => { + const { speech, spoken, finish } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + + // Six crossings land in a burst: #1 starts speaking immediately, #2..#6 queue up (depth 5). + for (let n = 1; n <= 6; n++) { + q.enqueue({ full: `pilot ${n} full`, short: `pilot ${n} short` }); + } + for (let i = 0; i < 6; i++) finish(); + + // #1 spoke full (nothing queued yet); #2/#3 dequeue over a >3 backlog → short (catch up); + // by #4 the backlog is back to 3 → full again. + expect(spoken).toEqual([ + 'pilot 1 full', + 'pilot 2 short', + 'pilot 3 short', + 'pilot 4 full', + 'pilot 5 full', + 'pilot 6 full' + ]); + }); + + it('cancel drops the backlog and stops the synth', () => { + const { speech, spoken, finish, cancelSpy } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + + q.enqueue({ full: 'a full', short: 'a' }); + q.enqueue({ full: 'b full', short: 'b' }); + q.enqueue({ full: 'c full', short: 'c' }); + expect(spoken).toEqual(['a full']); + + q.cancel(); + expect(cancelSpy).toHaveBeenCalled(); + expect(q.depth).toBe(0); + + // The cancelled utterance's late onend must not resurrect the dropped backlog. + finish(); + expect(spoken).toEqual(['a full']); + }); + + it('a stale onend from a cancelled utterance cannot start a second, concurrent chain', () => { + const { speech, spoken, finish } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + + q.enqueue({ full: 'old full', short: 'old' }); + q.cancel(); + // A fresh callout starts a new chain after the cancel… + q.enqueue({ full: 'new full', short: 'new' }); + q.enqueue({ full: 'newer full', short: 'newer' }); + expect(spoken).toEqual(['old full', 'new full']); + + // …then the OLD utterance's onend finally fires (real synths do this on cancel). It must be + // ignored — 'newer' only speaks when 'new' itself ends. + finish(); // old — stale, ignored + expect(spoken).toEqual(['old full', 'new full']); + finish(); // new — pumps newer + expect(spoken).toEqual(['old full', 'new full', 'newer full']); + }); + + it('pumps past an utterance error (onerror) rather than wedging the queue', () => { + const { speech, spoken, pending } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + q.enqueue({ full: 'a full', short: 'a' }); + q.enqueue({ full: 'b full', short: 'b' }); + + pending.shift()?.onerror?.(); // 'a' errors mid-speech + expect(spoken).toEqual(['a full', 'b full']); + }); + + it('is a silent no-op where the Web Speech API is unavailable', () => { + const q = new CalloutQueue(undefined); + expect(q.available).toBe(false); + expect(() => q.enqueue({ full: 'x', short: 'x' })).not.toThrow(); + expect(() => q.cancel()).not.toThrow(); + }); +}); + +describe('lapCalloutTexts / formatLapSeconds', () => { + it('speaks the formatted seconds to one decimal ("21.4")', () => { + expect(formatLapSeconds(21_400_000)).toBe('21.4'); + expect(formatLapSeconds(21_449_000)).toBe('21.4'); + expect(formatLapSeconds(9_960_000)).toBe('10.0'); + }); + + it('builds ", lap N, M.S" with the short ", lap N" fallback', () => { + expect(lapCalloutTexts('Maverick', 3, 21_400_000)).toEqual({ + full: 'Maverick, lap 3, 21.4', + short: 'Maverick, lap 3' + }); + }); + + it('skips the name when the resolver fell back (never speaks a raw ref)', () => { + expect(lapCalloutTexts(undefined, 2, 30_000_000)).toEqual({ + full: 'lap 2, 30.0', + short: 'lap 2' + }); + }); + + it('degrades to the short form when no lap time is carried', () => { + expect(lapCalloutTexts('Goose', 1, undefined)).toEqual({ + full: 'Goose, lap 1', + short: 'Goose, lap 1' + }); + }); +}); diff --git a/frontend/apps/rd-console/tests/endTones.svelte.test.ts b/frontend/apps/rd-console/tests/endTones.svelte.test.ts new file mode 100644 index 0000000..e30ce49 --- /dev/null +++ b/frontend/apps/rd-console/tests/endTones.svelte.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushSync } from 'svelte'; +import { useEndOfRaceTones } from '../src/lib/endTones.svelte.js'; + +/** + * Unit tests for the end-of-race tone scheduler: countdown pips at remaining 5…1s and the + * race-end buzzer at 0, for heats with a KNOWN fixed end only. Driven inside an `$effect.root` + * (the raceClock test pattern) with reactive `$state` for phase/heat/anchor/window; fake timers + * drive both the helper's `setInterval` and the anchored clock. Pins: + * • the full 5,4,3,2,1 + end sequence, each mark once per run; + * • nothing for a heat with no fixed end (First-to-N: window `undefined`); + * • stream re-renders with the same run don't replay; + * • a re-mount mid-race pre-marks the already-past pips silently (no replay burst); + * • a Restart (new `race_started_at`) counts down afresh; + * • leaving `Running` stops the schedule. + */ +describe('useEndOfRaceTones', () => { + const T0 = 1_000_000_000_000; // ms — the fake wall-clock anchor + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(T0); + }); + afterEach(() => vi.useRealTimers()); + + /** Run inside an owned effect root with reactive inputs; returns a handle + the fired log. */ + function harness(initial?: { + phase?: string; + heat?: string | undefined; + startedAtMicros?: number | null; + windowMicros?: number; + }) { + let phase = $state(initial?.phase ?? 'Scheduled'); + let heat = $state('heat' in (initial ?? {}) ? initial?.heat : 'heat-1'); + let startedAt = $state(initial?.startedAtMicros ?? null); + let windowMicros = $state(initial?.windowMicros); + // A dummy rev the getters touch, so a test can force an effect re-run with UNCHANGED values — + // simulating the live stream re-pushing the same state (new object, same content). + let rev = $state(0); + const fired: Array = []; + const cleanup = $effect.root(() => { + useEndOfRaceTones( + () => { + void rev; + return phase; + }, + () => heat, + () => startedAt, + () => windowMicros, + () => Date.now(), // the tests' stand-in for session.serverNowMs() + { + onCountdown: (n) => fired.push(n), + onRaceEnd: () => fired.push('end') + } + ); + }); + flushSync(); + return { + fired, + set(next: { + phase?: string; + heat?: string | undefined; + startedAtMicros?: number | null; + windowMicros?: number; + }) { + if ('phase' in next) phase = next.phase; + if ('heat' in next) heat = next.heat; + if ('startedAtMicros' in next) startedAt = next.startedAtMicros; + if ('windowMicros' in next) windowMicros = next.windowMicros; + flushSync(); + }, + /** Re-run the effect with unchanged values (a same-content live-stream push). */ + poke() { + rev += 1; + flushSync(); + }, + cleanup + }; + } + + it('fires 5,4,3,2,1 then the race-end buzzer, each exactly once, for a Timed run', () => { + // A 30s window starting at T0. + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000, windowMicros: 30_000_000 }); + vi.advanceTimersByTime(24_000); // remaining 6s — quiet so far + expect(h.fired).toEqual([]); + + vi.advanceTimersByTime(1_000); // remaining 5s + expect(h.fired).toEqual([5]); + vi.advanceTimersByTime(4_000); // remaining 1s — 4,3,2,1 landed on the way + expect(h.fired).toEqual([5, 4, 3, 2, 1]); + vi.advanceTimersByTime(1_000); // remaining 0 — the buzzer + expect(h.fired).toEqual([5, 4, 3, 2, 1, 'end']); + + // Past the end (the grace window keeps the heat Running): nothing re-fires. + vi.advanceTimersByTime(5_000); + expect(h.fired).toEqual([5, 4, 3, 2, 1, 'end']); + h.cleanup(); + }); + + it('fires NOTHING for a heat with no fixed end (First-to-N: window undefined)', () => { + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000, windowMicros: undefined }); + vi.advanceTimersByTime(300_000); + expect(h.fired).toEqual([]); + h.cleanup(); + }); + + it('does not replay marks when the live stream re-pushes the same run (effect re-runs)', () => { + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000, windowMicros: 10_000_000 }); + vi.advanceTimersByTime(7_000); // remaining 3s → 5,4,3 fired + expect(h.fired).toEqual([5, 4, 3]); + + // Same-content pushes re-run the $effect; the fired set survives — no replay burst. + h.poke(); + h.poke(); + expect(h.fired).toEqual([5, 4, 3]); + + // …and the remaining marks still land on time. + vi.advanceTimersByTime(3_000); + expect(h.fired).toEqual([5, 4, 3, 2, 1, 'end']); + h.cleanup(); + }); + + it('a re-mount mid-race does NOT replay the already-past pips (silent pre-mark)', () => { + // First mount rides the run down to remaining ~3.5s, then unmounts (navigation away). + const startedAtMicros = T0 * 1000; + const h1 = harness({ phase: 'Running', startedAtMicros, windowMicros: 20_000_000 }); + vi.advanceTimersByTime(16_500); // remaining 3.5s → 5,4 fired + expect(h1.fired).toEqual([5, 4]); + h1.cleanup(); + + // A fresh mount onto the SAME run at remaining 3.5s: the 5s/4s pips are already past — they + // pre-mark silently; only 3,2,1 + the buzzer fire from here. + const h2 = harness({ phase: 'Running', startedAtMicros, windowMicros: 20_000_000 }); + expect(h2.fired).toEqual([]); + vi.advanceTimersByTime(3_500); + expect(h2.fired).toEqual([3, 2, 1, 'end']); + h2.cleanup(); + }); + + it('mounting after the window end is fully silent (everything pre-marked)', () => { + const startedAtMicros = (T0 - 60_000) * 1000; // the window ended 30s ago + const h = harness({ phase: 'Running', startedAtMicros, windowMicros: 30_000_000 }); + vi.advanceTimersByTime(10_000); + expect(h.fired).toEqual([]); + h.cleanup(); + }); + + it('a Restart (new race_started_at) counts the fresh run down again', () => { + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000, windowMicros: 8_000_000 }); + vi.advanceTimersByTime(8_000); + expect(h.fired).toEqual([5, 4, 3, 2, 1, 'end']); + + // Abort/Restart: back through Scheduled, then a NEW run anchor for the same heat. + h.set({ phase: 'Scheduled', startedAtMicros: null }); + const t1 = T0 + 20_000; + vi.setSystemTime(t1); + h.set({ phase: 'Running', startedAtMicros: t1 * 1000 }); + vi.advanceTimersByTime(8_000); + expect(h.fired).toEqual([5, 4, 3, 2, 1, 'end', 5, 4, 3, 2, 1, 'end']); + h.cleanup(); + }); + + it('stops scheduling once the heat leaves Running (early ForceEnd → no buzzer)', () => { + const h = harness({ phase: 'Running', startedAtMicros: T0 * 1000, windowMicros: 10_000_000 }); + vi.advanceTimersByTime(6_000); // remaining 4s → 5,4 fired + expect(h.fired).toEqual([5, 4]); + + // The RD force-ends early: the heat folds to Unofficial before the window closes. + h.set({ phase: 'Unofficial' }); + vi.advanceTimersByTime(10_000); + expect(h.fired).toEqual([5, 4]); // no 3,2,1, and crucially no end buzzer + h.cleanup(); + }); + + it('holds quiet while Running before the server race-start has propagated', () => { + const h = harness({ phase: 'Running', startedAtMicros: null, windowMicros: 4_000_000 }); + vi.advanceTimersByTime(2_000); + expect(h.fired).toEqual([]); + // The anchor lands (race began at T0): remaining is 2s — the 5s/4s/3s marks pre-mark + // silently (already past), and 2,1 + the buzzer fire on time. + h.set({ startedAtMicros: T0 * 1000 }); + vi.advanceTimersByTime(2_000); + expect(h.fired).toEqual([2, 1, 'end']); + h.cleanup(); + }); +}); diff --git a/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts new file mode 100644 index 0000000..da5a88e --- /dev/null +++ b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { flushSync } from 'svelte'; +import type { PilotProgress } from '@gridfpv/types'; +import { useLapCallouts, type LapCrossing } from '../src/lib/lapCallouts.svelte.js'; + +/** + * Unit tests for the new-lap detector behind the audio callouts. Driven inside an `$effect.root` + * (the raceClock test pattern) with reactive `$state` for the live inputs. Pins: + * • a lap-count increase on the current Running heat fires once, with ref + lap + last-lap; + * • the first sight of a run BASELINES silently (late join / re-mount: no ghost narration); + * • nothing fires outside Running (a finished heat being corrected can never call out); + * • a count decrease (a live correction fold) resyncs silently; + * • a heat/run change re-baselines rather than diffing across heats. + */ +describe('useLapCallouts', () => { + const progressRow = ( + competitor: string, + laps: number, + lastLapMicros?: number + ): PilotProgress => ({ competitor, laps_completed: laps, last_lap_micros: lastLapMicros }); + + function harness(initial?: { + phase?: string; + heat?: string | undefined; + startedAtMicros?: number | null; + progress?: PilotProgress[]; + }) { + let phase = $state(initial?.phase ?? 'Running'); + let heat = $state('heat' in (initial ?? {}) ? initial?.heat : 'heat-1'); + let startedAt = $state(initial?.startedAtMicros ?? 1_000_000); + let progress = $state(initial?.progress ?? []); + const crossings: LapCrossing[] = []; + const cleanup = $effect.root(() => { + useLapCallouts( + () => phase, + () => heat, + () => startedAt, + () => progress, + (c) => crossings.push(c) + ); + }); + flushSync(); + return { + crossings, + set(next: { + phase?: string; + heat?: string | undefined; + startedAtMicros?: number | null; + progress?: PilotProgress[]; + }) { + if ('phase' in next) phase = next.phase; + if ('heat' in next) heat = next.heat; + if ('startedAtMicros' in next) startedAt = next.startedAtMicros; + if ('progress' in next) progress = next.progress ?? []; + flushSync(); + }, + cleanup + }; + } + + it('fires once per new lap on the current Running heat, carrying ref + lap + last-lap', () => { + const h = harness({ progress: [progressRow('maverick-1', 0), progressRow('goose-2', 0)] }); + expect(h.crossings).toEqual([]); + + h.set({ progress: [progressRow('maverick-1', 1, 21_400_000), progressRow('goose-2', 0)] }); + expect(h.crossings).toEqual([{ ref: 'maverick-1', lap: 1, lastLapMicros: 21_400_000 }]); + + // A same-content re-push (the stream re-emitting) fires nothing. + h.set({ progress: [progressRow('maverick-1', 1, 21_400_000), progressRow('goose-2', 0)] }); + expect(h.crossings).toHaveLength(1); + + // Both cross: one crossing each, in row order. + h.set({ + progress: [progressRow('maverick-1', 2, 20_900_000), progressRow('goose-2', 1, 22_000_000)] + }); + expect(h.crossings).toEqual([ + { ref: 'maverick-1', lap: 1, lastLapMicros: 21_400_000 }, + { ref: 'maverick-1', lap: 2, lastLapMicros: 20_900_000 }, + { ref: 'goose-2', lap: 1, lastLapMicros: 22_000_000 } + ]); + h.cleanup(); + }); + + it('BASELINES silently on first sight of a run (late join / re-mount mid-race)', () => { + // Mounting onto a heat already at laps 7/6 must not narrate history. + const h = harness({ + progress: [progressRow('maverick-1', 7, 20_000_000), progressRow('goose-2', 6, 21_000_000)] + }); + expect(h.crossings).toEqual([]); + + // The NEXT lap after the baseline calls out normally. + h.set({ + progress: [progressRow('maverick-1', 8, 19_800_000), progressRow('goose-2', 6, 21_000_000)] + }); + expect(h.crossings).toEqual([{ ref: 'maverick-1', lap: 8, lastLapMicros: 19_800_000 }]); + h.cleanup(); + }); + + it('fires nothing outside Running — corrections on a finished heat can never call out', () => { + const h = harness({ + phase: 'Unofficial', + progress: [progressRow('maverick-1', 3, 20_000_000)] + }); + // A marshaling correction bumps the count on the (finished) heat's fold: silent. + h.set({ progress: [progressRow('maverick-1', 4, 20_000_000)] }); + expect(h.crossings).toEqual([]); + h.cleanup(); + }); + + it('re-baselines when the heat returns to Running (post-fold counts are not "new laps")', () => { + const h = harness({ progress: [progressRow('maverick-1', 0)] }); + h.set({ progress: [progressRow('maverick-1', 1, 25_000_000)] }); + expect(h.crossings).toHaveLength(1); + + // The heat finishes, then a NEW run of the same heat starts (Restart → new anchor): the + // fresh run baselines at its own first snapshot; no callouts for pre-existing counts. + h.set({ phase: 'Unofficial' }); + h.set({ + phase: 'Running', + startedAtMicros: 2_000_000, + progress: [progressRow('maverick-1', 0)] + }); + expect(h.crossings).toHaveLength(1); + h.set({ progress: [progressRow('maverick-1', 1, 24_000_000)] }); + expect(h.crossings).toHaveLength(2); + expect(h.crossings[1]).toEqual({ ref: 'maverick-1', lap: 1, lastLapMicros: 24_000_000 }); + h.cleanup(); + }); + + it('a count DECREASE (a live correction fold) resyncs silently', () => { + const h = harness({ progress: [progressRow('maverick-1', 0)] }); + h.set({ progress: [progressRow('maverick-1', 2, 20_000_000)] }); + expect(h.crossings).toHaveLength(1); // one increase event → one callout (lap 2) + + // A correction folds the count back down: no callout. + h.set({ progress: [progressRow('maverick-1', 1, 20_000_000)] }); + expect(h.crossings).toHaveLength(1); + + // The next genuine crossing (back up to 2) announces lap 2 again — correct after the fold. + h.set({ progress: [progressRow('maverick-1', 2, 19_000_000)] }); + expect(h.crossings).toHaveLength(2); + expect(h.crossings[1]).toEqual({ ref: 'maverick-1', lap: 2, lastLapMicros: 19_000_000 }); + h.cleanup(); + }); + + it('a heat SWAP re-baselines — the new heat\'s existing counts are not "new laps"', () => { + const h = harness({ heat: 'heat-1', progress: [progressRow('maverick-1', 0)] }); + // The stream swaps to a different, already-running heat carrying non-zero counts. + h.set({ + heat: 'heat-2', + startedAtMicros: 5_000_000, + progress: [progressRow('carla-3', 4, 23_000_000)] + }); + expect(h.crossings).toEqual([]); + + // Its next lap calls out. + h.set({ progress: [progressRow('carla-3', 5, 22_500_000)] }); + expect(h.crossings).toEqual([{ ref: 'carla-3', lap: 5, lastLapMicros: 22_500_000 }]); + h.cleanup(); + }); + + it('fires nothing while there is no current heat', () => { + const h = harness({ heat: undefined, progress: [progressRow('maverick-1', 0)] }); + h.set({ progress: [progressRow('maverick-1', 1, 20_000_000)] }); + expect(h.crossings).toEqual([]); + h.cleanup(); + }); +}); diff --git a/frontend/apps/rd-console/tests/raceAudio.test.ts b/frontend/apps/rd-console/tests/raceAudio.test.ts new file mode 100644 index 0000000..60e880d --- /dev/null +++ b/frontend/apps/rd-console/tests/raceAudio.test.ts @@ -0,0 +1,288 @@ +/** + * Unit tests for the race-day audio player (grown out of the heat-lifecycle Slice 3 start tone). + * A **mock `AudioContext`** is injected so we can assert — with no real audio hardware — the two + * scopes of the tone model: + * • PROCEDURE tones (start tone, end-of-race countdown pip, race-end buzzer) play + * unconditionally — the callouts mute must NOT silence them; + * • the INFORMATIONAL crossing pip is mute-scoped; + * plus each tone's pitch/length (start ≈800ms @880Hz, pip ≈150ms @880Hz, buzzer lower+longer + * ≈600ms @440Hz, crossing higher+very short ≈60ms @1760Hz) and the autoplay-policy + * resume-then-schedule fix. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { RaceAudioPlayer, type ToneAudioContext } from '../src/lib/raceAudio.js'; + +/** A spy oscillator record: the frequency it was set to, whether it started, and its length. */ +interface OscRecord { + freq: number; + started: boolean; + /** `stop(when) - start(when)` in seconds — the burst length (dur + the 0.02s release pad). */ + lengthSecs: number; +} + +function makeMockContext(state = 'running'): { + ctx: ToneAudioContext; + oscillators: OscRecord[]; + resumeSpy: ReturnType; +} { + const oscillators: OscRecord[] = []; + const resumeSpy = vi.fn(async () => {}); + const ctx: ToneAudioContext = { + currentTime: 0, + state, + destination: {}, + createOscillator() { + const rec: OscRecord = { freq: 0, started: false, lengthSecs: 0 }; + let startedAt = 0; + oscillators.push(rec); + return { + type: 'sine' as OscillatorType, + frequency: { + setValueAtTime(value: number) { + rec.freq = value; + } + }, + connect() {}, + start(when?: number) { + startedAt = when ?? 0; + rec.started = true; + }, + stop(when?: number) { + rec.lengthSecs = (when ?? 0) - startedAt; + } + }; + }, + createGain() { + return { + gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, + connect() {} + }; + }, + resume: resumeSpy, + close: async () => {} + }; + return { ctx, oscillators, resumeSpy }; +} + +/** A minimal in-memory Storage (this jsdom config provides no real localStorage). */ +function makeMemoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (k: string) => map.get(k) ?? null, + key: (i: number) => [...map.keys()][i] ?? null, + removeItem: (k: string) => void map.delete(k), + setItem: (k: string, v: string) => void map.set(k, v) + }; +} + +describe('RaceAudioPlayer', () => { + let storage: Storage; + beforeEach(() => { + // A fresh in-memory storage per test so the persisted mute pref doesn't bleed across cases. + storage = makeMemoryStorage(); + vi.stubGlobal('localStorage', storage); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('procedure tones (always on)', () => { + it('plays the start tone at 880Hz for ~800ms by default (doubled from the old 400ms)', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + + player.playStartTone(); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0].started).toBe(true); + expect(oscillators[0].freq).toBe(880); + // 0.8s burst + the 0.02s release pad. + expect(oscillators[0].lengthSecs).toBeCloseTo(0.82, 5); + }); + + it('still honours the configured round cue (hz/ms) for the start tone', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + + player.playStartTone({ hz: 440, ms: 250 }); + + expect(oscillators[0].freq).toBe(440); + expect(oscillators[0].lengthSecs).toBeCloseTo(0.27, 5); + }); + + it('the start tone plays EVEN WHILE MUTED — procedure tones ignore the callouts mute', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: true }); + + player.playStartTone(); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0].started).toBe(true); + }); + + it('the countdown pip is short (~150ms) in the start-tone pitch family (880Hz), unmuted by the toggle', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: true }); + + player.playCountdownBeep(); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0].freq).toBe(880); + expect(oscillators[0].lengthSecs).toBeCloseTo(0.17, 5); + }); + + it('the race-end buzzer is LOWER (half pitch, 440Hz) and LONGER (~600ms), unmuted by the toggle', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: true }); + + player.playRaceEndTone(); + player.playCountdownBeep(); + + expect(oscillators[0].freq).toBe(440); + expect(oscillators[0].lengthSecs).toBeCloseTo(0.62, 5); + // Explicitly lower + longer than the countdown pip. + expect(oscillators[0].freq).toBe(oscillators[1].freq / 2); + expect(oscillators[0].lengthSecs).toBeGreaterThan(oscillators[1].lengthSecs); + }); + }); + + describe('informational layer (mute-scoped)', () => { + it('plays the crossing pip — higher (1760Hz) + very short (~60ms) — while unmuted', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + + player.playCrossingBeep(); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0].freq).toBe(1760); + expect(oscillators[0].lengthSecs).toBeCloseTo(0.08, 5); + }); + + it('the crossing pip is a no-op while muted — the ONLY tone the mute silences', () => { + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: true }); + + player.playCrossingBeep(); + expect(oscillators).toHaveLength(0); + + // …while every procedure tone still sounds. + player.playStartTone(); + player.playCountdownBeep(); + player.playRaceEndTone(); + expect(oscillators).toHaveLength(3); + }); + + it('a mute toggled during the (suspended-path) resume suppresses the crossing pip', async () => { + const { ctx, oscillators, resumeSpy } = makeMockContext('suspended'); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + resumeSpy.mockImplementation(async () => { + player.setMuted(true); // RD hits mute during the brief resume window + }); + player.playCrossingBeep(); + await Promise.resolve(); + await Promise.resolve(); + expect(oscillators).toHaveLength(0); + }); + + it('a mute toggled during the resume does NOT suppress a procedure tone', async () => { + const { ctx, oscillators, resumeSpy } = makeMockContext('suspended'); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + resumeSpy.mockImplementation(async () => { + player.setMuted(true); + }); + player.playStartTone(); + await Promise.resolve(); + await Promise.resolve(); + expect(oscillators).toHaveLength(1); + }); + }); + + describe('mute preference (callouts scope)', () => { + it('toggleMuted flips and persists under the NEW callouts key', () => { + const { ctx } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + + expect(player.toggleMuted()).toBe(true); + expect(storage.getItem('gridfpv.callouts.muted')).toBe('true'); + expect(player.toggleMuted()).toBe(false); + expect(storage.getItem('gridfpv.callouts.muted')).toBe('false'); + }); + + it('a fresh player reads the persisted callouts pref', () => { + storage.setItem('gridfpv.callouts.muted', 'true'); + const { ctx, oscillators } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx }); + expect(player.muted).toBe(true); + player.playCrossingBeep(); + expect(oscillators).toHaveLength(0); + }); + + it('IGNORES the old start-tone mute key — its meaning changed (it silenced procedure tones)', () => { + storage.setItem('gridfpv.startTone.muted', 'true'); + const { ctx } = makeMockContext(); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx }); + expect(player.muted).toBe(false); + }); + }); + + describe('autoplay policy (resume-then-schedule)', () => { + it('resumes a suspended context (autoplay-policy unlock)', async () => { + const { ctx, resumeSpy } = makeMockContext('suspended'); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + await player.resume(); + expect(resumeSpy).toHaveBeenCalled(); + }); + + it('on a *suspended* context, resumes BEFORE starting the oscillator (the audible-tone fix)', async () => { + // The race-go edge is an auto transition (no click), so the context can still be suspended. + // Scheduling against a frozen suspended clock and only resuming after means the note never + // sounds — the original "no tone" bug. Assert playStartTone() resumes first, then emits. + const { ctx, oscillators, resumeSpy } = makeMockContext('suspended'); + const order: string[] = []; + resumeSpy.mockImplementation(async () => { + order.push('resume'); + }); + const origCreate = ctx.createOscillator.bind(ctx); + ctx.createOscillator = () => { + order.push('oscillator'); + return origCreate(); + }; + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + + player.playStartTone(); + // Let the resume() promise + its .then() emit chain settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(resumeSpy).toHaveBeenCalled(); + expect(oscillators).toHaveLength(1); + expect(oscillators[0].started).toBe(true); + // Resume must precede the oscillator so the note is scheduled on the *resumed* clock. + expect(order).toEqual(['resume', 'oscillator']); + }); + + it('on a *running* context, plays synchronously without an extra resume', () => { + const { ctx, oscillators, resumeSpy } = makeMockContext('running'); + const player = new RaceAudioPlayer({ audioContextFactory: () => ctx, initialMuted: false }); + player.playStartTone(); + expect(oscillators).toHaveLength(1); + expect(oscillators[0].started).toBe(true); + expect(resumeSpy).not.toHaveBeenCalled(); + }); + }); + + it('is a silent no-op when Web Audio is unavailable (no factory)', () => { + // No platform AudioContext and no injected factory → unavailable; nothing throws. + const player = new RaceAudioPlayer({ audioContextFactory: undefined, initialMuted: false }); + expect(player.available).toBe(false); + expect(() => player.playStartTone()).not.toThrow(); + expect(() => player.playCountdownBeep()).not.toThrow(); + expect(() => player.playRaceEndTone()).not.toThrow(); + expect(() => player.playCrossingBeep()).not.toThrow(); + }); +}); diff --git a/frontend/apps/rd-console/tests/startTone.test.ts b/frontend/apps/rd-console/tests/startTone.test.ts deleted file mode 100644 index e319323..0000000 --- a/frontend/apps/rd-console/tests/startTone.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Unit tests for the start-tone player (heat-lifecycle Slice 3). A **mock `AudioContext`** is - * injected so we can assert — with no real audio hardware — that the tone plays at race-go, that - * mute suppresses it, and that the configured/default frequency is used. - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { StartTonePlayer, type ToneAudioContext } from '../src/lib/startTone.js'; - -/** A spy oscillator capturing the frequency it was set to and whether it was started. */ -function makeMockContext(state = 'running'): { - ctx: ToneAudioContext; - oscillators: Array<{ freq: number; started: boolean }>; - resumeSpy: ReturnType; -} { - const oscillators: Array<{ freq: number; started: boolean }> = []; - const resumeSpy = vi.fn(async () => {}); - const ctx: ToneAudioContext = { - currentTime: 0, - state, - destination: {}, - createOscillator() { - const rec = { freq: 0, started: false }; - oscillators.push(rec); - return { - type: 'sine' as OscillatorType, - frequency: { - setValueAtTime(value: number) { - rec.freq = value; - } - }, - connect() {}, - start() { - rec.started = true; - }, - stop() {} - }; - }, - createGain() { - return { - gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, - connect() {} - }; - }, - resume: resumeSpy, - close: async () => {} - }; - return { ctx, oscillators, resumeSpy }; -} - -/** A minimal in-memory Storage (this jsdom config provides no real localStorage). */ -function makeMemoryStorage(): Storage { - const map = new Map(); - return { - get length() { - return map.size; - }, - clear: () => map.clear(), - getItem: (k: string) => map.get(k) ?? null, - key: (i: number) => [...map.keys()][i] ?? null, - removeItem: (k: string) => void map.delete(k), - setItem: (k: string, v: string) => void map.set(k, v) - }; -} - -describe('StartTonePlayer', () => { - let storage: Storage; - beforeEach(() => { - // A fresh in-memory storage per test so the persisted mute pref doesn't bleed across cases. - storage = makeMemoryStorage(); - vi.stubGlobal('localStorage', storage); - }); - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('plays an oscillator burst at race-go (default tone) when unmuted', () => { - const { ctx, oscillators } = makeMockContext(); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - - player.play(); - - expect(oscillators).toHaveLength(1); - expect(oscillators[0].started).toBe(true); - // The default tone is 880Hz when no cue is supplied. - expect(oscillators[0].freq).toBe(880); - }); - - it('uses the configured tone frequency from the round cue', () => { - const { ctx, oscillators } = makeMockContext(); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - - player.play({ hz: 440, ms: 250 }); - - expect(oscillators[0].freq).toBe(440); - }); - - it('is a no-op while muted — no oscillator is created', () => { - const { ctx, oscillators } = makeMockContext(); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: true }); - - player.play(); - - expect(oscillators).toHaveLength(0); - }); - - it('toggleMuted flips and persists the preference, gating the next play', () => { - const { ctx, oscillators } = makeMockContext(); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - - expect(player.toggleMuted()).toBe(true); // now muted - player.play(); - expect(oscillators).toHaveLength(0); - // The preference persisted to storage. - expect(storage.getItem('gridfpv.startTone.muted')).toBe('true'); - - expect(player.toggleMuted()).toBe(false); // unmuted again - player.play(); - expect(oscillators).toHaveLength(1); - }); - - it('a fresh player reads the persisted mute preference', () => { - storage.setItem('gridfpv.startTone.muted', 'true'); - const { ctx, oscillators } = makeMockContext(); - const player = new StartTonePlayer({ audioContextFactory: () => ctx }); - expect(player.muted).toBe(true); - player.play(); - expect(oscillators).toHaveLength(0); - }); - - it('resumes a suspended context (autoplay-policy unlock)', async () => { - const { ctx, resumeSpy } = makeMockContext('suspended'); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - await player.resume(); - expect(resumeSpy).toHaveBeenCalled(); - }); - - it('on a *suspended* context, resumes BEFORE starting the oscillator (the audible-tone fix)', async () => { - // The race-go edge is an auto transition (no click), so the context can still be suspended. - // Scheduling against a frozen suspended clock and only resuming after means the note never - // sounds — the original "no tone" bug. Assert play() resumes first, then emits an oscillator. - const { ctx, oscillators, resumeSpy } = makeMockContext('suspended'); - const order: string[] = []; - resumeSpy.mockImplementation(async () => { - order.push('resume'); - }); - const origCreate = ctx.createOscillator.bind(ctx); - ctx.createOscillator = () => { - order.push('oscillator'); - return origCreate(); - }; - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - - player.play(); - // Let the resume() promise + its .then() emit chain settle. - await Promise.resolve(); - await Promise.resolve(); - - expect(resumeSpy).toHaveBeenCalled(); - expect(oscillators).toHaveLength(1); - expect(oscillators[0].started).toBe(true); - // Resume must precede the oscillator so the note is scheduled on the *resumed* clock. - expect(order).toEqual(['resume', 'oscillator']); - }); - - it('on a *running* context, plays synchronously without an extra resume', () => { - const { ctx, oscillators, resumeSpy } = makeMockContext('running'); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - player.play(); - // Already running → fire immediately; no resume needed on this path. - expect(oscillators).toHaveLength(1); - expect(oscillators[0].started).toBe(true); - expect(resumeSpy).not.toHaveBeenCalled(); - }); - - it('a mute toggled during the resume suppresses the (suspended-path) tone', async () => { - const { ctx, oscillators, resumeSpy } = makeMockContext('suspended'); - const player = new StartTonePlayer({ audioContextFactory: () => ctx, initialMuted: false }); - resumeSpy.mockImplementation(async () => { - player.setMuted(true); // RD hits mute during the brief resume window - }); - player.play(); - await Promise.resolve(); - await Promise.resolve(); - expect(oscillators).toHaveLength(0); - }); - - it('is a silent no-op when Web Audio is unavailable (no factory)', () => { - // No platform AudioContext and no injected factory → unavailable; play never throws. - const player = new StartTonePlayer({ audioContextFactory: undefined, initialMuted: false }); - expect(player.available).toBe(false); - expect(() => player.play()).not.toThrow(); - }); -}); From cebf5d0aeeba7b7d8deba0462de364b3b4fd8f49 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 01:15:58 +0000 Subject: [PATCH 329/362] fix(console): Classes & Roster and Timers pages fill the content width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both carried a root max-width (48rem / 44rem) that the other event pages (Rounds, Race control, …) don't, so they rendered as a narrow column while everything else expanded. Dropped both — they now use the full content area like their siblings. Co-Authored-By: Claude Fable 5 --- frontend/apps/rd-console/src/screens/EventClassesRoster.svelte | 1 - frontend/apps/rd-console/src/screens/EventTimers.svelte | 1 - 2 files changed, 2 deletions(-) diff --git a/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte b/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte index 9844f26..d072f0e 100644 --- a/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte +++ b/frontend/apps/rd-console/src/screens/EventClassesRoster.svelte @@ -723,7 +723,6 @@ diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts index 291d12c..5497265 100644 --- a/frontend/packages/components/src/format.ts +++ b/frontend/packages/components/src/format.ts @@ -9,13 +9,18 @@ import type { Metric } from '@gridfpv/types'; -/** Format a clock duration in **milliseconds** as `M:SS.mmm` (e.g. `1:23.456`). */ +/** + * Format a clock duration in **milliseconds** as `M:SS.mmm` (e.g. `1:23.456`). A negative value — + * a countdown past its zero (a timed heat inside its grace window) — renders sign-prefixed + * (`-0:03.250`). + */ export function formatClock(ms: number): string { - const totalMs = Math.max(0, Math.floor(ms)); + const totalMs = Math.floor(Math.abs(ms)); const minutes = Math.floor(totalMs / 60000); const seconds = Math.floor((totalMs % 60000) / 1000); const millis = totalMs % 1000; - return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; + const sign = ms < 0 ? '-' : ''; + return `${sign}${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; } /** diff --git a/frontend/packages/components/tests/RaceClock.test.ts b/frontend/packages/components/tests/RaceClock.test.ts index ca2a377..b7509c3 100644 --- a/frontend/packages/components/tests/RaceClock.test.ts +++ b/frontend/packages/components/tests/RaceClock.test.ts @@ -14,6 +14,26 @@ describe('RaceClock', () => { expect(container.querySelector('[data-mode="remaining"]')).not.toBeNull(); }); + it('grades countdown urgency: ok while comfortable, closing ≤10s, over past zero', () => { + // Comfortable: normal color (no warn tint) so the countdown isn't crying wolf all race. + const ok = render(RaceClock, { remainingMs: 25_000 }); + expect(ok.container.querySelector('[data-urgency="ok"]')).not.toBeNull(); + ok.unmount(); + // The closing stretch: warn-colored. + const closing = render(RaceClock, { remainingMs: 9_000 }); + expect(closing.container.querySelector('[data-urgency="closing"]')).not.toBeNull(); + closing.unmount(); + // Past zero (the grace window): a NEGATIVE, sign-prefixed readout, danger-colored. + const over = render(RaceClock, { remainingMs: -3_250 }); + expect(screen.getByText('-0:03.250')).toBeInTheDocument(); + expect(over.container.querySelector('[data-urgency="over"]')).not.toBeNull(); + }); + + it('an elapsed (count-up) clock carries no urgency grade', () => { + const { container } = render(RaceClock, { elapsedMs: 61_000 }); + expect(container.querySelector('[data-urgency]')).toBeNull(); + }); + it('exposes an accessible timer label', () => { render(RaceClock, { elapsedMs: 0, label: 'Lap timer' }); expect(screen.getByRole('timer')).toHaveAttribute('aria-label', 'Lap timer: 0:00.000'); diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts index c49bd5e..2805f7e 100644 --- a/frontend/packages/components/tests/format.test.ts +++ b/frontend/packages/components/tests/format.test.ts @@ -8,8 +8,10 @@ describe('formatClock', () => { expect(formatClock(83_456)).toBe('1:23.456'); expect(formatClock(605_007)).toBe('10:05.007'); }); - it('clamps negatives to zero', () => { - expect(formatClock(-100)).toBe('0:00.000'); + it('renders negatives sign-prefixed (a countdown inside its grace window)', () => { + expect(formatClock(-100)).toBe('-0:00.100'); + expect(formatClock(-3_250)).toBe('-0:03.250'); + expect(formatClock(-83_456)).toBe('-1:23.456'); }); }); From 7d79494de2ed935fd6bd3b4d2c05a40e426cdc98 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:42:10 +0000 Subject: [PATCH 331/362] feat(engine): heat-tagged passes + fold/scoring integrity fixes (D19/D20 wire side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The results-integrity half of the July 2026 release audit (docs/release-audit-2026-07.html): - Pass gains an optional heat tag, stamped by the bridge's PassSink at append time (the adapter doesn't know the heat; the router does). Every fold — the heat window, the class window, the live state, the completion clock's pass window — attributes a tagged pass BY TAG, never by log position alone, so a heat-span event landing mid-race (filling the next round, marshaling a finished heat) can no longer close the running heat's span and silently drop its remaining laps. Untagged (legacy-log) passes keep the positional rule and replay unchanged. (D20) - Discarded joins Aborted/Restarted as a run-reset boundary: a discarded heat no longer shows (or re-scores) the thrown-away run's laps. Caught live in the soak; regression-tested. (D19) - Heat/class-scoped live views fold with PRESERVED global offsets (live_state_over + class_window_offsets): marshaling adjudications target global LogRefs, so the old re-enumerated filtered folds ignored a throw-out — or hit the wrong pass — in every heat-scoped live view. The class scope's snapshot and stream now also fold the same window (they used to diverge). - Live running order breaks equal-lap ties on last-lap COMPLETION time (the scorer's rule), not lap duration — the overlay no longer contradicts the scored Timed result; multi-adapter last-lap picks the temporally latest. - HeadToHead overrides Generator::advancers: each heat's winner(s), deduped across rotations, a DQ never advances. The default position- --- bindings/LiveRaceState.ts | 9 + bindings/Pass.ts | 12 +- crates/adapters/src/clock.rs | 1 + crates/adapters/src/dedup.rs | 1 + crates/adapters/src/lib.rs | 1 + crates/adapters/src/rotorhazard.rs | 8 + crates/adapters/src/velocidrone.rs | 4 + crates/app/src/lib.rs | 1 + crates/app/src/source.rs | 40 +++- crates/engine/src/event.rs | 1 + crates/engine/src/head_to_head.rs | 59 ++++++ crates/engine/src/scoring.rs | 1 + crates/engine/src/timed_qual.rs | 61 +++++- crates/events/src/lib.rs | 11 ++ crates/projection/src/lib.rs | 9 +- crates/server/src/app.rs | 47 ++++- crates/server/src/control_handler.rs | 1 + crates/server/src/live_state.rs | 274 +++++++++++++++++++++++++-- crates/server/src/open_practice.rs | 1 + crates/server/src/round_engine.rs | 51 ++++- crates/server/src/ws.rs | 22 ++- crates/storage/tests/behaviour.rs | 2 + 22 files changed, 571 insertions(+), 46 deletions(-) diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts index 7593358..6b3f1f0 100644 --- a/bindings/LiveRaceState.ts +++ b/bindings/LiveRaceState.ts @@ -65,6 +65,15 @@ race_started_at?: number, * 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 diff --git a/bindings/Pass.ts b/bindings/Pass.ts index 64d4651..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"; @@ -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/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 94b5445..87a5acf 100644 --- a/crates/adapters/src/rotorhazard.rs +++ b/crates/adapters/src/rotorhazard.rs @@ -817,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. @@ -1240,6 +1242,8 @@ impl RotorHazardAdapter { 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; @@ -1389,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 { @@ -1404,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 { @@ -1415,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 { @@ -1426,6 +1433,7 @@ mod tests { signal: Some(SignalContext { rssi_peak: Some(0.0), }), + heat: None, }), // DONE (2) ends the session. Event::SessionEnded { 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/app/src/lib.rs b/crates/app/src/lib.rs index 565570c..8581914 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -74,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/source.rs b/crates/app/src/source.rs index 73dfbfc..528fedd 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -154,6 +154,11 @@ pub struct PassSink { /// 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 { @@ -166,6 +171,7 @@ impl PassSink { gate: None, timer: None, open_practice: None, + heat: None, } } @@ -183,6 +189,7 @@ impl PassSink { gate: Some(gate), timer: Some(timer), open_practice: None, + heat: None, } } @@ -195,6 +202,14 @@ impl PassSink { 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 { @@ -225,6 +240,7 @@ impl PassSink { 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. @@ -262,6 +278,15 @@ impl PassSink { 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:?}")))?; @@ -904,7 +929,8 @@ fn handle_transition( 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); + PassSink::gated(state.clone(), adapter.clone(), gate.clone(), timer_id) + .for_heat(heat.clone()); if open_practice { sink = sink.for_open_practice(heat.clone()); } @@ -932,7 +958,8 @@ fn handle_transition( adapter.clone(), gate.clone(), timer_id.clone(), - ); + ) + .for_heat(heat.clone()); if open_practice { sink = sink.for_open_practice(heat.clone()); } @@ -1712,7 +1739,14 @@ fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { } _ => running = false, }, - Event::Pass(p) if running && p.gate.is_lap_gate() => passes.push(p), + // 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() => { + if p.heat.as_ref().is_none_or(|h| h == heat) { + passes.push(p); + } + } _ => {} } } diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 3a7a587..55658d9 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -323,6 +323,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, }) } diff --git a/crates/engine/src/head_to_head.rs b/crates/engine/src/head_to_head.rs index 9ec129d..5980a43 100644 --- a/crates/engine/src/head_to_head.rs +++ b/crates/engine/src/head_to_head.rs @@ -146,6 +146,32 @@ impl HeadToHead { } 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 @@ -631,6 +657,39 @@ mod tests { 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- 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, } } @@ -274,7 +286,8 @@ impl Generator for TimedQualifying { let key = if place.disqualified { None } else { - self.metric.round_key(place.metric, place.laps) + self.metric + .round_key(place.metric, place.laps, place.best_lap_micros) }; let slot = best .entry(place.competitor.competitor.clone()) @@ -603,6 +616,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. diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index d54259c..1308007 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -253,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) ------------------------------------------- @@ -844,6 +853,7 @@ mod tests { sequence: Some(3), gate: GateIndex::LAP, signal: None, + heat: None, } } @@ -1318,6 +1328,7 @@ mod tests { sequence: None, gate: GateIndex::LAP, signal: None, + heat: None, }) ); // And the new facts themselves deserialize from their on-the-wire shape. diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index daa6def..0d7df35 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -320,8 +320,8 @@ where competitor, at, // The heat tag routes the insertion into the right heat *window* upstream; - // by the time the fold sees it, it is just a synthetic pass. - heat: _, + // the synthetic pass carries it through so tag-aware folds agree. + heat, } => { entries.insert( offset, @@ -333,6 +333,7 @@ where sequence: None, gate: gridfpv_events::GateIndex::LAP, signal: None, + heat: heat.clone(), }), ); } @@ -460,6 +461,7 @@ where sequence: None, gate: gridfpv_events::GateIndex::LAP, signal: None, + heat: src.heat, }, )); } @@ -974,6 +976,7 @@ mod tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, }) } @@ -986,6 +989,7 @@ mod tests { sequence: None, gate: GateIndex(gate), signal: Some(SignalContext { rssi_peak: None }), + heat: None, }) } @@ -1250,6 +1254,7 @@ mod marshaling_tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, }) } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 5ace603..4ce8aa5 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -103,7 +103,7 @@ use crate::events::{ RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state, with_heat_timing}; +use crate::live_state::{HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; @@ -1618,12 +1618,15 @@ async fn snapshot_class( 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_events = class_window(&events, &class); + 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(&class_events), &stored)), + body: ProjectionBody::LiveRaceState(with_heat_timing( + live_state_over(&class_offsets), + &stored, + )), })) } @@ -1637,6 +1640,16 @@ async fn snapshot_class( /// [`heat_window`] 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. pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { + class_window_offsets(events, class) + .into_iter() + .map(|(_, e)| e) + .collect() +} + +/// [`class_window`] carrying 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() @@ -1654,16 +1667,24 @@ pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { // `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 event in events { + 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(event.clone()); + window.push((offset, event.clone())); } } - // Passes and adjudications belong to whichever heat is currently active. - _ if active => window.push(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`). + Event::Pass(p) if p.heat.is_some() => { + if p.heat.as_ref().is_some_and(|h| class_heats.contains(h)) { + window.push((offset, event.clone())); + } + } + // Untagged passes and adjudications belong to whichever heat is currently active. + _ if active => window.push((offset, event.clone())), _ => {} } } @@ -1712,7 +1733,7 @@ async fn snapshot_heat( ProjectionBody::LiveRaceState( state .open_practice() - .merge_into(with_heat_timing(live_state(&heat_events), &stored)), + .merge_into(with_heat_timing(live_state_over(&heat_offsets), &stored)), ) } HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled( @@ -1896,7 +1917,14 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, | Event::RulingReversed { target } => { claimed.contains(&target.0) && offset >= run_start } - // Untagged (passes, legacy insertions, registrations): positional. + // 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. + Event::Pass(p) => match &p.heat { + Some(h) => h == heat && offset >= run_start, + None => active && offset >= run_start, + }, + // Untagged (legacy insertions, registrations, …): positional. _ => active && offset >= run_start, }; if include { @@ -2002,6 +2030,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, }) } diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index e44cb29..684e0e6 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -1444,6 +1444,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, }) } diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 833fbf3..51b880e 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -107,6 +107,15 @@ pub struct LiveRaceState { #[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 @@ -168,6 +177,7 @@ impl Default for LiveRaceState { on_deck: None, race_started_at: None, race_ended_at: None, + staged_at: None, tone_at: None, lifecycle: None, } @@ -207,6 +217,32 @@ pub struct PilotProgress { /// (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) +} + +/// 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 { + // 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) +} + +/// 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)]) -> LiveRaceState { let Some(current_heat) = current_heat(events) else { return LiveRaceState::default(); }; @@ -230,18 +266,41 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { // boundary, so everything counts (a normally-finalized heat is unaffected). let run_start = current_run_start(events, ¤t_heat); let laps = lap_list_marshaled( - events + window .iter() .enumerate() - .filter(|(i, _)| *i >= run_start) - .map(|(i, e)| (i as u64, e)), + .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. + match e { + Event::Pass(p) => p.heat.as_ref().is_none_or(|h| h == ¤t_heat), + _ => true, + } + }) + .map(|(_, (offset, e))| (*offset, *e)), ); - let mut by_ref: BTreeMap<&CompetitorRef, (u32, Option)> = BTreeMap::new(); + // 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)); + let entry = by_ref.entry(competitor).or_insert((0, None, None)); entry.0 += cl.lap_count() as u32; - entry.1 = cl.laps.last().map(|l| l.duration_micros).or(entry.1); + 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 @@ -258,8 +317,8 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { let progress: Vec = active_pilots .iter() .map(|competitor| { - let (laps_completed, last_lap_micros) = - by_ref.get(competitor).copied().unwrap_or((0, None)); + 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()), @@ -269,7 +328,13 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { }) .collect(); - let running_order = running_order(&progress); + // 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()), @@ -283,6 +348,7 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { // `&[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, @@ -405,6 +471,12 @@ pub fn with_heat_timing(mut live: LiveRaceState, stored: &[StoredEvent]) -> Live 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: @@ -427,6 +499,23 @@ pub fn with_heat_timing(mut live: LiveRaceState, stored: &[StoredEvent]) -> Live 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, + }) + .last() +} + /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. fn phase_of(state: HeatState) -> HeatPhase { match state { @@ -470,7 +559,7 @@ fn current_heat(events: &[Event]) -> Option { /// /// - its most recent `Running` transition (the run started here — passes only arrive while Running), /// **or** -/// - one past its most recent reset (`Aborted` / `Restarted`). +/// - 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 @@ -501,7 +590,12 @@ pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { // 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. - HeatTransition::Aborted | HeatTransition::Restarted => start = i + 1, + // `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, _ => {} } } @@ -569,9 +663,39 @@ pub(crate) fn round_of_heat(events: &[Event], heat: &HeatId) -> Option latest_schedule(events, heat).2 } -/// Rank active pilots into the provisional running order: most laps first, ties broken -/// by the shorter last-lap time (a proxy for who is pacing ahead), then by competitor -/// ref for a total, deterministic order. +/// 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| { @@ -748,6 +872,20 @@ mod tests { 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())), }) } @@ -1100,6 +1238,114 @@ mod tests { ); } + #[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 diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs index 8341cf5..071c54a 100644 --- a/crates/server/src/open_practice.rs +++ b/crates/server/src/open_practice.rs @@ -246,6 +246,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, } } diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 33b6c22..4f2df27 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -528,10 +528,11 @@ fn heat_winners( depth: usize, ) -> Result, FillError> { // The carry is the source format's **advancing set** — `Generator::advancers`, not a - // ranking-position heuristic. Single-elim overrides it to return each heat's top `advance` - // finishers, which is correct for a 4-up level too (a position-`< worst` filter would wrongly - // keep a 4-up heat's 3rd-place losers, since the eliminated do not share one worst band). Built - // from the same field + log as `round_ranking_at`, one depth deeper (the seeding-cycle guard). + // 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 @@ -1165,6 +1166,10 @@ fn placement_laps(result: &HeatResult, competitor: &CompetitorRef) -> u32 { .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) } @@ -1200,11 +1205,19 @@ pub fn class_standings( let mut acc: BTreeMap = BTreeMap::new(); for round in rounds_for_class(meta, class) { - let ranking = round_ranking(meta, round, events)?; - let field_size = ranking.len() as u32; // 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. @@ -1915,6 +1928,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, }) } @@ -3622,6 +3636,31 @@ mod tests { 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"); diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs index cd7151e..64e140f 100644 --- a/crates/server/src/ws.rs +++ b/crates/server/src/ws.rs @@ -81,10 +81,10 @@ use gridfpv_events::{CompetitorRef, Event}; use gridfpv_projection::{LapList, lap_list_marshaled}; use gridfpv_storage::StoredEvent; -use crate::app::{AppState, heat_window, resolve_event}; +use crate::app::{AppState, resolve_event}; use crate::error::{ErrorCode, ProtocolError}; use crate::events::EventRegistry; -use crate::live_state::{live_state, with_heat_timing}; +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}; @@ -190,7 +190,7 @@ impl ScopeProjection { let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); let events = events.as_slice(); match scope { - Scope::Event { .. } | Scope::Class { .. } => { + 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). @@ -200,6 +200,18 @@ impl ScopeProjection { } 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). @@ -212,8 +224,8 @@ impl ScopeProjection { // 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 = heat_window(events, heat); - let mut live = with_heat_timing(live_state(&window), stored); + 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); diff --git a/crates/storage/tests/behaviour.rs b/crates/storage/tests/behaviour.rs index c6baa3b..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(), From 85fad8759b2114bf6fb093ff9d79c8dc061cb7ba Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:43:20 +0000 Subject: [PATCH 332/362] fix(runtime): timed auto-end fallback + per-heat clocks, no-replay startup, atomic writes (D19/D21/D22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-integrity half of the July 2026 release audit (docs/release-audit-2026-07.html): - A Timed heat always ends: race_end_reached needs a crossing AT/AFTER the window close, so a field that lands at the buzzer left the heat Running forever (the stuck 'Short Time Heat 1'). The completion driver now also holds a wall-clock deadline — window + grace, anchored to the first OBSERVED pass (race-go when nobody crossed) — late but never early. (D19) - Runtime clocks are per-heat maps, not single slots: protest/grace windows legitimately overlap across heats, and 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. Regression-tested with overlapping protest windows + a mid-window Discard. (D21) - The bridge starts from the LOG TAIL: a Director restart replays no history (historical Armed/Running used to re-fire drivers and sim sources, corrupting scored windows), and a heat caught mid-flight (Armed/Running/Unofficial) is handed once to the normal transition handler — fresh clocks over current state, so a mid-race restart still auto-ends the race. A transient log read error now retries instead of permanently killing the event's runtime. (D21) - Every driver re-checks the heat's folded state AT FIRE TIME (still Armed / Running / Unofficial, and no open protest for the auto-official) under the new command serialization lock — the poll-paced cancel left ~200ms where an expiring clock's stale transition landed after a manual Revert/ForceEnd. - The command serialization lock (AppState::command_guard / append_checked) spans validate→append for every control command, including the multi-step fill / advance / schedule paths — closing the TOCTOU on every gate. (D22) - An RH connection superseded by an event switch yields the shared timer-status cell to its successor: its async teardown used to stomp Disconnected over the new connection's Connecting/Connected, tripping failover on a healthy timer. Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 513 +++++++++++++++++++++--- crates/app/src/source/rh_connections.rs | 13 +- crates/app/src/source/rotorhazard.rs | 39 +- crates/server/src/app.rs | 38 ++ crates/server/src/control_handler.rs | 15 + 5 files changed, 551 insertions(+), 67 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 528fedd..1481079 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -559,13 +559,43 @@ pub(crate) async fn run_bridge( adapter: AdapterId, #[cfg(feature = "live")] connections: RhConnections, ) { - let mut cursor: Offset = 0; // 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): the start countdown for a heat in `Armed` - // and the completion clock for a heat in `Running`. Each is `(heat, task)` so the bridge can - // cancel a superseded one (the heat left the relevant state) before it appends a stale auto. + // 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 { @@ -604,8 +634,17 @@ pub(crate) async fn run_bridge( let new_events = match read_tail(&state, cursor) { Ok(batch) => batch, - // A poisoned lock (or a dropped log at shutdown) ends the bridge cleanly. - Err(_) => return, + // 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; @@ -976,10 +1015,11 @@ fn handle_transition( // 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. - clock.completion = Some(( + HeatClock::install( + &mut clock.completion, heat.clone(), spawn_completion_driver(state, registry, event_id, heat.clone()), - )); + ); if handles.is_empty() && nothing_armed { return; @@ -1043,10 +1083,11 @@ fn handle_transition( transition, HeatTransition::Finished | HeatTransition::Reverted ) { - clock.protest = Some(( + 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 @@ -1090,28 +1131,31 @@ fn handle_transition( // `Armed → Running`. A manual `SkipCountdown` (or an abort) cancels this via `cancel_for` // before it fires — see the top of this fn. HeatTransition::Armed => { - clock.start = Some(( + 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): the `start` -/// countdown for the heat currently in `Armed` and the `completion` clock for the heat currently in -/// `Running`. Each is `(heat, task)` so a transition can cancel exactly the driver belonging to the -/// heat that moved. At most one of each at a time (the bridge drives one heat at a time). +/// 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 { - /// The start countdown for a heat in `Armed` (appends `HeatStarting` then auto `Running`). - start: Option<(HeatId, JoinHandle<()>)>, - /// The completion clock for a heat in `Running` (appends auto `Finished` on win + grace). - completion: Option<(HeatId, JoinHandle<()>)>, - /// The **auto-official timer** for a heat in `Unofficial` (marshaling Slice 5): when the round - /// armed a protest window, it logs the deadline (`HeatFinalizing`) then appends the auto - /// `Finalized` once the window elapses. Absent for a round with no protest window (the default). - protest: Option<(HeatId, JoinHandle<()>)>, + /// 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 { @@ -1119,23 +1163,26 @@ impl HeatClock { /// 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((h, task)) = &self.start { - if h == heat { - task.abort(); - self.start = None; - } + if let Some(task) = self.start.remove(heat) { + task.abort(); } - if let Some((h, task)) = &self.completion { - if h == heat { - task.abort(); - self.completion = None; - } + if let Some(task) = self.completion.remove(heat) { + task.abort(); } - if let Some((h, task)) = &self.protest { - if h == heat { - task.abort(); - self.protest = None; - } + 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(); } } } @@ -1184,6 +1231,34 @@ impl ActiveHeat { /// 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() @@ -1505,16 +1580,27 @@ fn spawn_start_driver( return; } tokio::time::sleep(Duration::from_millis(delay_ms as u64)).await; - // Auto-advance Armed → Running. If the heat already left Armed (a manual SkipCountdown / an - // abort), this task has been cancelled by the bridge and never reaches here. - if let Err(e) = state.append( + // 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) + } + }; + match state.append_checked( Event::HeatStateChanged { heat, transition: HeatTransition::Running, }, None, + still_armed, ) { - eprintln!("gridfpv: start driver could not append Running: {e:?}"); + Ok(_) => {} + Err(e) => eprintln!("gridfpv: start driver could not append Running: {e:?}"), } }) } @@ -1529,6 +1615,12 @@ fn spawn_start_driver( /// 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 @@ -1544,6 +1636,17 @@ fn spawn_completion_driver( 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 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 @@ -1554,6 +1657,10 @@ fn spawn_completion_driver( .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 @@ -1562,13 +1669,7 @@ fn spawn_completion_driver( // 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) = state.append( - Event::HeatStateChanged { - heat, - transition: HeatTransition::Finished, - }, - None, - ) { + if let Err(e) = append_finished_if_running(&state, &heat) { eprintln!( "gridfpv: completion driver could not append time-limit Finished: {e:?}" ); @@ -1577,6 +1678,28 @@ fn spawn_completion_driver( } } 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) { + 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 }; @@ -1584,13 +1707,7 @@ fn spawn_completion_driver( // 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) = state.append( - Event::HeatStateChanged { - heat, - transition: HeatTransition::Finished, - }, - None, - ) { + if let Err(e) = append_finished_if_running(&state, &heat) { eprintln!("gridfpv: completion driver could not append Finished: {e:?}"); } return; @@ -1599,6 +1716,30 @@ fn spawn_completion_driver( }) } +/// 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, +) -> 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) + }, + ) + .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 @@ -1700,17 +1841,27 @@ fn spawn_auto_official_driver( return; } } - // Auto-finalize Unofficial → Final. If the heat already left Unofficial (a manual early - // Finalize, a Revert, an abort), this task has been cancelled by the bridge and never reaches - // here — so the auto-finalize never fights a manual action. - if let Err(e) = state.append( + // 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) + && open_protest_count(events, &h) == 0 + }; + match state.append_checked( Event::HeatStateChanged { heat, transition: HeatTransition::Finalized, }, None, + gate, ) { - eprintln!("gridfpv: auto-official driver could not append Finalized: {e:?}"); + Ok(_) => {} + Err(e) => eprintln!("gridfpv: auto-official driver could not append Finalized: {e:?}"), } }) } @@ -2524,6 +2675,244 @@ mod tests { 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, + }; + 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 diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs index 5e1a3b4..e215e2c 100644 --- a/crates/app/src/source/rh_connections.rs +++ b/crates/app/src/source/rh_connections.rs @@ -143,10 +143,19 @@ impl RhConnections { .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) { - // Tear down on the driver thread (stop race + disconnect + leave Disconnected). - conn.cancel(); + 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(); + } } } diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index 6e8070c..c097438 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -180,6 +180,11 @@ fn error_chain(err: &dyn std::error::Error) -> String { 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 @@ -211,22 +216,35 @@ impl RhConnection { /// 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, armed, tune, prepare, seat); + drive( + url, + timer_id, + timers, + cancel, + yield_status, + armed, + tune, + prepare, + seat, + ); }) }; Self { cancel, + yield_status, armed, tune, prepare, @@ -302,6 +320,14 @@ impl RhConnection { 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 { @@ -393,11 +419,13 @@ fn claim_finish_flags(finishing: bool, done: &mut bool, settle_pending: bool) -> /// 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)] +#[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, @@ -493,8 +521,13 @@ fn drive( backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); } } - // Cancelled: leave the timer Disconnected (deselected / event changed / shutdown). - timers.set_status(&timer_id, TimerStatus::Disconnected); + // 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 diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 4ce8aa5..a651a8a 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -198,6 +198,14 @@ pub struct AppState { /// 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 { @@ -210,9 +218,37 @@ impl AppState { 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 { @@ -221,6 +257,7 @@ impl AppState { appended: Arc::new(Notify::new()), tokens: TokenStore::new(), open_practice: crate::open_practice::OpenPracticeLive::new(), + commands: Arc::new(Mutex::new(())), } } @@ -235,6 +272,7 @@ impl AppState { appended: Arc::new(Notify::new()), tokens, open_practice: crate::open_practice::OpenPracticeLive::new(), + commands: Arc::new(Mutex::new(())), } } diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 684e0e6..f52456d 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -369,6 +369,9 @@ fn apply_schedule_heat( ) -> 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, @@ -700,6 +703,10 @@ pub fn apply_fill_round( 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, @@ -773,6 +780,9 @@ fn apply_advance( 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) { @@ -858,6 +868,11 @@ fn select_next_heat(state: &AppState, next: HeatId) -> CommandAck { /// [`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(), From f5689b5cd49bb08d89c14766325882f835fa1260 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:44:04 +0000 Subject: [PATCH 333/362] feat(server): raced-round freeze, run-scoped protests, param + penalty validation (D23/D24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy half of the July 2026 release audit (docs/release-audit-2026-07.html): - A RACED round's scoring-defining config is frozen: format, classes, win condition, seeding, channel mode, and format params can no longer change once a heat has left Scheduled — scoring re-derives from the round's CURRENT config, so editing them silently re-scored already-official results (a config-side bypass of the Final lock) and re-seeding rewrote bracket chains. Still editable: label, staging timer, start procedure, grace, protest window, race time — and the rounds (heats-per-pilot) count, which only extends future fills. A round with heats can no longer be removed (its heats would strand). (D24) - A protest dies with the run it contests: filings before the heat's latest reset (Abort/Restart/Discard) no longer gate Finalize — the field re-races anyway. The old whole-log predicate deadlocked the RD: a pre-Restart protest blocked finalization forever while no run-windowed view could show it. (D23) - Rulings apply effectively once: a filing takes ONE resolution (a second — double-click, second console — was recorded, possibly contradictory, and made reversal silently not take) and a heat takes ONE void. Reverse the standing ruling to re-decide. (D23) - Validation batch (raw-API guards the forms already implied): a Timed window must be positive, First-to-N needs n >= 1, a set time limit >= 1s (each used to end heats at the start signal — worse now that D19 auto-closes); a time penalty must ADD time (a negative 'penalty' IMPROVED the pilot); a heat lineup can't be empty; declared format params are validated against the format schema at create/update (garbage used to surface only at fill time, mid-event); a FillRound(All) that hits the runaway cap acks FAILED instead of ok-with-a-thousand-junk-heats. Co-Authored-By: Claude Fable 5 --- crates/server/src/control_handler.rs | 172 +++++++++++++++++- crates/server/src/events.rs | 252 +++++++++++++++++++++++++- crates/server/tests/control.rs | 8 +- frontend/contract/control.contract.ts | 10 +- 4 files changed, 420 insertions(+), 22 deletions(-) diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index f52456d..f3e9b58 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -107,7 +107,7 @@ use axum::response::Response; use axum::routing::get; use axum::{Router, routing::MethodRouter}; use gridfpv_engine::heat::{self, HeatCommand}; -use gridfpv_events::{Event, HeatId, LogRef}; +use gridfpv_events::{Event, HeatId, HeatTransition, LogRef}; use crate::app::{AppState, resolve_event}; use crate::control::{Command, CommandAck, FillMode}; @@ -456,6 +456,13 @@ fn require_new_heat_id(state: &AppState, heat: &HeatId) -> Result<(), ProtocolEr /// 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()) { @@ -751,8 +758,15 @@ pub fn apply_fill_round( this indicates a generator bug, not a {MAX_FILL_ALL_HEATS}-heat round.", round.0, ); - // We still appended up to the cap; ack ok so the RD sees the heats that were drawn. - CommandAck::ok() + // 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" + ), + )) } } } @@ -1032,6 +1046,9 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_scheduled_heat(state, &heat)?; require_not_final(state, &heat)?; + // One EFFECTIVE void per heat: a stacked second void made the first reversal a + // silent no-op (the heat stayed voided behind an ok-acked ReverseRuling). + require_heat_not_voided(state, &heat)?; Ok(Event::HeatVoided { heat }) } Command::ApplyPenalty { @@ -1041,6 +1058,17 @@ fn command_to_event(state: &AppState, command: Command) -> Result { 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", + )); + } + } Ok(Event::PenaltyApplied { heat, competitor, @@ -1091,6 +1119,12 @@ fn command_to_event(state: &AppState, command: Command) -> Result { 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 } => { @@ -1391,17 +1425,91 @@ pub fn open_protest_count(events: &[Event], heat: &HeatId) -> usize { _ => None, }) .collect(); - // Filed protests for this heat with no effective resolution are still open. + // 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, + }) + .last() + .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 voided = events.iter().enumerate().any(|(offset, e)| { + matches!(e, Event::HeatVoided { heat: h } if h == heat) + && !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. @@ -2368,28 +2476,72 @@ mod tests { 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(), }; - // Just a filing → open. - assert_eq!(open_protest_count(std::slice::from_ref(&filed), &h), 1); + // 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(0), + target: LogRef(1), outcome: ProtestOutcome::Denied, }, ]; assert_eq!(open_protest_count(&resolved, &h), 0); - // Reversing the resolution (at offset 1) re-opens the protest. + // Reversing the resolution (at offset 2) re-opens the protest. let mut reversed = resolved.clone(); - reversed.push(Event::RulingReversed { target: LogRef(1) }); + 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(&[filed], &HeatId("other".into())), 0); + assert_eq!(open_protest_count(&base, &HeatId("other".into())), 0); + } + + #[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 diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index ecc5bdb..75b576b 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -1413,6 +1413,7 @@ impl EventRegistry { req.time_limit_secs, None, )?; + validate_round_params(&req.format, &req.params)?; // 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. @@ -1460,12 +1461,49 @@ impl EventRegistry { /// 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 @@ -1473,9 +1511,9 @@ impl EventRegistry { .get_mut(id) .ok_or_else(|| RoundError::EventNotFound(id.0.clone()))?; - if !event.meta.rounds.iter().any(|r| &r.id == round_id) { + 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. @@ -1494,6 +1532,52 @@ impl EventRegistry { req.time_limit_secs, Some(round_id), )?; + validate_round_params(&req.format, &req.params)?; + + // 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"); + } + // 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(), @@ -1533,6 +1617,18 @@ impl EventRegistry { /// ([`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 @@ -2168,7 +2264,26 @@ fn validate_round_fields( )); } } - + // 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!( @@ -2213,6 +2328,59 @@ fn validate_round_fields( 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`]. +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). @@ -3051,6 +3219,84 @@ mod tests { 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, + }; + + // 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(); diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs index c651488..1921837 100644 --- a/crates/server/tests/control.rs +++ b/crates/server/tests/control.rs @@ -293,7 +293,7 @@ async fn control_ws_survives_a_malformed_frame() { &mut control, &Command::ScheduleHeat { heat: heat(), - lineup: vec![], + lineup: vec![CompetitorRef("A".into())], class: None, round: None, frequencies: vec![], @@ -313,7 +313,7 @@ async fn control_post_requires_a_valid_rd_token() { let (addr, rd, _server) = serve(registry.clone()).await; let cmd = Command::ScheduleHeat { heat: heat(), - lineup: vec![], + lineup: vec![CompetitorRef("A".into())], class: None, round: None, frequencies: vec![], @@ -373,7 +373,7 @@ async fn control_ws_upgrade_requires_a_valid_rd_token() { &mut control, &Command::ScheduleHeat { heat: heat(), - lineup: vec![], + lineup: vec![CompetitorRef("A".into())], class: None, round: None, frequencies: vec![], @@ -442,7 +442,7 @@ async fn out_of_band_contract_version_is_told_to_refresh() { &addr, &Command::ScheduleHeat { heat: heat(), - lineup: vec![], + lineup: vec![CompetitorRef("A".into())], class: None, round: None, frequencies: vec![], diff --git a/frontend/contract/control.contract.ts b/frontend/contract/control.contract.ts index 6b5ddc0..2c54bb4 100644 --- a/frontend/contract/control.contract.ts +++ b/frontend/contract/control.contract.ts @@ -139,7 +139,7 @@ describe('seam 5: control command shape + headers', () => { it('a missing Content-Type → a JSON ProtocolError(BadRequest), not a bare-text 4xx', async () => { const { status, body } = await postControl( director.baseUrl, - { ScheduleHeat: { heat: 'h-noct', lineup: [] } }, + { ScheduleHeat: { heat: 'h-noct', lineup: ['PILOT-A'] } }, { token: TOKEN, contentType: false } ); // The control endpoint now answers the uniform `ProtocolError` JSON shape every other API @@ -231,7 +231,7 @@ describe('seam 5: control command shape + headers', () => { describe('seam 6: auth gates control, reads stay open', () => { it('control with NO token → 401 ProtocolError(Unauthorized)', async () => { const { status, body } = await postControl(director.baseUrl, { - ScheduleHeat: { heat: 'h-noauth', lineup: [] } + ScheduleHeat: { heat: 'h-noauth', lineup: ['PILOT-A'] } }); expect(status).toBe(401); expect((body as { code?: string }).code).toBe('Unauthorized'); @@ -240,7 +240,7 @@ describe('seam 6: auth gates control, reads stay open', () => { it('control with an UNKNOWN/revoked token → 401', async () => { const { status } = await postControl( director.baseUrl, - { ScheduleHeat: { heat: 'h-badtok', lineup: [] } }, + { ScheduleHeat: { heat: 'h-badtok', lineup: ['PILOT-A'] } }, { token: 'not-a-real-token' } ); expect(status).toBe(401); @@ -248,7 +248,7 @@ describe('seam 6: auth gates control, reads stay open', () => { it('control with the valid RD token → accepted', async () => { const ack = await rdControl(director.baseUrl, TOKEN, { - ScheduleHeat: { heat: 'h-goodtok', lineup: [] } + ScheduleHeat: { heat: 'h-goodtok', lineup: ['PILOT-A'] } }); expect(ack.ok).toBe(true); }); @@ -306,7 +306,7 @@ describe('seam 6: auth gates control, reads stay open', () => { // …but it is REJECTED on CONTROL — a spectator can watch, never run the race. const { status, body } = await postControl( director.baseUrl, - { ScheduleHeat: { heat: 'h-join-rejected', lineup: [] } }, + { ScheduleHeat: { heat: 'h-join-rejected', lineup: ['PILOT-A'] } }, { token: join } ); expect(status).toBe(401); From 9a214e26882194422f30e2489efc4836094cc351 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:45:24 +0000 Subject: [PATCH 334/362] fix(console): marshaling/results hardening, durable names, protocol resume + audit docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console + protocol half of the July 2026 release audit, plus the docs integration (docs/release-audit-2026-07.html is the ledger; D19-D24 record the decisions): - Marshaling: projections are heat-keyed with latest-wins refreshes (a pin switch or failed fetch can no longer display — or let the RD act on — the wrong heat's laps/audit/trace); the ruling/protest/add-lap pilot list falls back to the MARSHALED heat's lineup, never the live heat's active_pilots (a DQ against a pilot who never flew the heat); node-seat names resolve only from this heat's bindings; already-reversed rulings leave the reverse picker; double-submit guards on every correction; Split/Edit/Insert require a positive time (no more AdjustLap at=0); penalty amounts validate visibly. - Durable friendly names (the standing rule): Audit rows, Results placements, and the JSON export resolve refs through each heat's own window bindings, so a finished node-seeded heat never renders raw node-N seats; EventRounds lineups go through the shared resolver with the channel-label fallback, and class/round labels show a placeholder — never a raw id — while loading. - Session/Results correctness: leaveEvent clears the signal trace (the previous event's RSSI no longer renders as 'evidence' under the next event's heat); Results view-switch fetches are latest-wins; a failed heats read keeps the last-good list with a visible retry; heatResult clears when the heat moves on or is Reverted (no stale rows in the JSON export); auth-failure detection reads the HTTP status structurally (a 500 mentioning '401' no longer opens the token dialog). - Round form: a seeding the form can't model (FromHeatWinners bracket advancement / ranges / combine) is preserved verbatim and locked — an unrelated edit can no longer rewrite a bracket chain to FromRoster. - Staging countdown is server-anchored: the live state's new staged_at instant against serverNowMs(), so every console (and a reload) reads the SAME window. - Protocol client: the resume cursor advances as envelopes apply (a reconnect no longer replays the entire backlog through the UI) and an unhandled Delta envelope fails safe into re-snapshot instead of a silent freeze. Contract pins added for GET /time, StartProcedure's tagged shape, the SignalTrace projection, and the null-competitor audit row. - Docs: the release-audit ledger (new page, indexed), decisions D19-D24, and the Heat Lifecycle / Race Engine / Marshaling / Live State & Clock sections brought in line with the shipped semantics. Co-Authored-By: Claude Fable 5 --- docs/decisions.html | 135 +++++++ docs/heat-lifecycle.html | 16 +- docs/index.html | 8 + docs/live-state-clock.html | 7 + docs/marshaling.html | 13 + docs/race-engine.html | 7 +- docs/release-audit-2026-07.html | 156 ++++++++ .../apps/rd-console/src/lib/session.svelte.ts | 116 +++++- .../rd-console/src/lib/stagingClock.svelte.ts | 29 +- .../rd-console/src/screens/EventAudit.svelte | 31 +- .../rd-console/src/screens/EventRounds.svelte | 137 +++++-- .../src/screens/LiveRaceControl.svelte | 6 +- .../rd-console/src/screens/Marshaling.svelte | 333 ++++++++++++------ .../rd-console/src/screens/Results.svelte | 110 ++++-- .../apps/rd-console/tests/EventAudit.test.ts | 37 ++ .../apps/rd-console/tests/EventRounds.test.ts | 76 ++++ .../rd-console/tests/MarshalingScreen.test.ts | 98 +++++- .../rd-console/tests/ResultsScreen.test.ts | 127 +++++++ .../rd-console/tests/session.svelte.test.ts | 160 ++++++++- frontend/apps/rd-console/tests/support.ts | 49 ++- frontend/contract/audit.contract.ts | 5 + frontend/contract/events.contract.ts | 78 ++++ frontend/contract/snapshot.contract.ts | 16 + frontend/contract/time.contract.ts | 63 ++++ .../protocol-client/src/client.test.ts | 99 +++++- .../packages/protocol-client/src/client.ts | 46 ++- 26 files changed, 1730 insertions(+), 228 deletions(-) create mode 100644 docs/release-audit-2026-07.html create mode 100644 frontend/contract/time.contract.ts diff --git a/docs/decisions.html b/docs/decisions.html index 2533834..4aea33c 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -595,6 +595,141 @@

    D18 · One grouping decision per round (extends D17)

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

    Internal design documentation · ← back to docs index diff --git a/docs/heat-lifecycle.html b/docs/heat-lifecycle.html index fb410ac..939d967 100644 --- a/docs/heat-lifecycle.html +++ b/docs/heat-lifecycle.html @@ -207,7 +207,10 @@

    4.2 Completion — Running → Unofficial

    • 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.
    • + 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 @@ -222,6 +225,17 @@

      4.2 Completion — Running → Unofficial

      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 diff --git a/docs/index.html b/docs/index.html index 3eb144f..53af3af 100644 --- a/docs/index.html +++ b/docs/index.html @@ -238,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 index 0acf210..915a03a 100644 --- a/docs/live-state-clock.html +++ b/docs/live-state-clock.html @@ -83,6 +83,13 @@

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

      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 diff --git a/docs/marshaling.html b/docs/marshaling.html index f6a0e48..d3e6c6c 100644 --- a/docs/marshaling.html +++ b/docs/marshaling.html @@ -234,6 +234,19 @@

      3.3 Differentiator — the governance layer nobody has

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

      4. Architectural fit

      diff --git a/docs/race-engine.html b/docs/race-engine.html index 63bf47c..5de9cbc 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -97,7 +97,12 @@

      2. The heat loop

      Passes are consumed only while a heat is Running (and during a grace window after it goes Unofficial — late crossings still count; the window is - configurable). The phases: Stage begins the (informational) staging countdown + 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 diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html new file mode 100644 index 0000000..8520d1d --- /dev/null +++ b/docs/release-audit-2026-07.html @@ -0,0 +1,156 @@ + + + + + + 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. Deferred — recorded, deliberately not built

      +
        +
      • 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.
      • +
      • Orphan bridge pruning: a deleted event's bridge task holds a strong log + handle for the process lifetime — a resource leak, no correctness impact.
      • +
      • Presence-reconciler duplicate bindings within one poll batch — benign + (same key/value, last-wins fold).
      • +
      • Version-skew rendering: an additive server enum variant renders as a + blank metric cell / mislabeled timer kind on a stale console build. Mitigated by the + both-halves deploy rule and the new contract pins; a graceful-unknown rendering pass is + future polish.
      • +
      • StartProcedure form round-trip: the round form always writes + randomized-delay — lossless today (one mode exists), lossy the day a second + mode ships. The wire shape is now contract-pinned so that day is loud, not silent.
      • +
      + + +
      + + diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 0f2335f..c22e8b4 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -145,13 +145,36 @@ function isAuthAck(ack: CommandAck): boolean { } /** - * Whether a thrown error from `createEvent` is an HTTP **401/403** — i.e. the Director is - * gating event creation. `createEvent` rejects with an `Error` whose message carries the HTTP - * status (`POST /events failed: HTTP 401`), so we match on that. + * The HTTP status a thrown request error carries, if any: a structural `status` field when the + * error provides one, else the trailing `… failed: HTTP ` every protocol-client request + * rejection ends with. Anything else — a transport error, or a message that merely *contains* + * "401" somewhere (an event id like `evt-401`, a 500 body echoing a token) — resolves + * `undefined`, so it can never masquerade as an auth status. (The old whole-message + * `\b(401|403)\b` scan opened the token dialog on exactly those.) */ -function isAuthFailure(e: unknown): boolean { +function httpStatusOf(e: unknown): number | undefined { + if ( + e && + typeof e === 'object' && + 'status' in e && + typeof (e as { status: unknown }).status === 'number' + ) { + return (e as { status: number }).status; + } const msg = e instanceof Error ? e.message : String(e); - return /\b(401|403)\b/.test(msg); + const m = /\bfailed: HTTP (\d{3})$/.exec(msg); + return m ? Number(m[1]) : undefined; +} + +/** + * Whether a thrown error from `createEvent` (and the other gated writes) is an HTTP **401/403** + * — i.e. the Director is gating the action. Matched on the error's HTTP *status* (structural, or + * the protocol client's anchored `failed: HTTP ` suffix — see {@link httpStatusOf}), + * never on digits appearing anywhere in the message. + */ +function isAuthFailure(e: unknown): boolean { + const status = httpStatusOf(e); + return status === 401 || status === 403; } /** @@ -241,6 +264,16 @@ export class Session { * tighter heat-scope read (`?projection=result`), so the Results screen reads this. */ heatResult = $state.raw(undefined); + /** + * The **durable per-heat registration bindings** (competitor ref → pilot id), one map per heat, + * read off each heat's own live-state fold (`?projection=live` over that heat's log window — the + * same durable source Marshaling's `heatLiveState` uses) and cached for the life of the event. + * The event-wide surfaces (Results, Audit) resolve competitor names from THIS, never just the + * global live stream — whose `progress` only carries the *current* heat, so a finished + * node-seeded heat's `node-0` seats rendered raw (the friendly-names rule, CLAUDE.md). + * Populated on demand via {@link ensureHeatBindings}; `$state.raw`: replaced wholesale per pull. + */ + heatBindings = $state.raw>>(new Map()); /** The last control-path error surfaced to the RD (cleared on the next send). */ lastCommandError = $state(undefined); /** @@ -303,6 +336,10 @@ export class Session { #client: ProtocolClient | undefined; #control: ControlClient | undefined; #unsub: (() => void) | undefined; + /** The heat {@link heatResult} was fetched for — so a heat change / Revert can drop it. */ + #heatResultHeat: HeatId | undefined; + /** Heats whose {@link heatBindings} read is in flight (dedupes concurrent `ensure` calls). */ + #heatBindingsInFlight = new Set(); /** The shell's lazy token prompt (a `Dialog`); set via {@link setTokenProvider}. */ #tokenProvider: TokenProvider | undefined; /** The live-status poll interval while inside an event; cleared on leave/teardown. */ @@ -1134,6 +1171,7 @@ export class Session { this.protocolState = state; this.connectionStatus = state.status; this.liveState = liveStateOf(state.body); + this.#dropStaleHeatResult(); }); // Begin polling the timer registry's live status for the header pills (#73, Slice 2b). @@ -1263,6 +1301,7 @@ export class Session { this.protocolState = state; this.connectionStatus = state.status; this.liveState = liveStateOf(state.body); + this.#dropStaleHeatResult(); }); } @@ -1285,9 +1324,18 @@ export class Session { this.protocolState = undefined; this.liveState = undefined; this.heatResult = undefined; + this.#heatResultHeat = undefined; + // The per-heat binding cache is event-scoped (heat ids and node-seat binds from one event + // must never resolve names under the next). + this.heatBindings = new Map(); + this.#heatBindingsInFlight.clear(); this.lapList = undefined; this.marshalingAudit = undefined; this.heatLiveState = undefined; + // The signal trace too — leaving it set rendered the PREVIOUS event's RSSI evidence under + // the next event's heat when its own trace read failed or lagged (fabricated evidence on + // the defensible-results surface). + this.signalTrace = undefined; this.lastCommandError = undefined; this.timers = []; } @@ -1374,6 +1422,12 @@ export class Session { // setToken rebuilt #control with the token; resend on the new client. ack = (await this.#control?.sendCommand(command)) ?? ack; } + // A successful Revert re-opens the heat's result — a stored scored {@link heatResult} for + // that heat no longer stands, so drop it (the Results export must not embed it). + if (ack.ok && 'Revert' in command && command.Revert.heat === this.#heatResultHeat) { + this.heatResult = undefined; + this.#heatResultHeat = undefined; + } this.lastCommandError = ack.ok ? undefined : ack.error; return ack; } @@ -1413,6 +1467,7 @@ export class Session { 'HeatResult' in snap.body ) { this.heatResult = (snap.body as { HeatResult: HeatResult }).HeatResult; + this.#heatResultHeat = heat; return this.heatResult; } } catch { @@ -1421,6 +1476,57 @@ export class Session { return undefined; } + /** + * Drop {@link heatResult} once it no longer describes the heat it was fetched for: it is set on + * Finalize and was previously never cleared, so after moving on to the next heat the Results + * JSON export embedded the PREVIOUS heat's result. Called on every live-stream state — clears + * when the current heat moves off {@link #heatResultHeat}; {@link send} additionally clears it + * when that heat's result is **Reverted** (re-opened, so no scored result stands). + */ + #dropStaleHeatResult(): void { + if (this.heatResult === undefined) return; + const current = this.liveState?.current_heat; + if (current != null && current !== this.#heatResultHeat) { + this.heatResult = undefined; + this.#heatResultHeat = undefined; + } + } + + /** + * Ensure {@link heatBindings} holds the **durable registration bindings** for each of `heats`, + * fetching any missing heat's own live-state fold (`?projection=live` — the heat-window fold + * whose `progress[].pilot` carries the heat's `CompetitorRegistered` binds; see + * {@link refreshMarshaling}'s note) and caching the extracted ref → pilot map. Already-cached + * and in-flight heats are skipped, so the event-wide screens can call this freely per render + * tick. A failed read leaves the heat uncached (a later call retries); the resolver then simply + * falls back for that heat's refs rather than erroring (#340 doesn't apply — these enrich + * names, the primary reads surface their own failures). + */ + async ensureHeatBindings(heats: HeatId[]): Promise { + const missing = heats.filter( + (h) => !this.heatBindings.has(h) && !this.#heatBindingsInFlight.has(h) + ); + if (missing.length === 0) return; + for (const h of missing) this.#heatBindingsInFlight.add(h); + try { + const folds = await Promise.all( + missing.map((h) => this.#fetchHeatProjection(h, 'live', 'LiveRaceState')) + ); + const next = new Map(this.heatBindings); + let changed = false; + folds.forEach((live, i) => { + if (!live) return; // failed read — stay uncached so the next ensure retries + const bound = new Map(); + for (const p of live.progress ?? []) if (p.pilot != null) bound.set(p.competitor, p.pilot); + next.set(missing[i], bound); + changed = true; + }); + if (changed) this.heatBindings = next; + } finally { + for (const h of missing) this.#heatBindingsInFlight.delete(h); + } + } + /** * Set the session role (#80 auth tiers). `'readonly'` hides/disables mutating controls across * the console (e.g. the Marshaling actions); `'rd'` restores them. The Director still enforces diff --git a/frontend/apps/rd-console/src/lib/stagingClock.svelte.ts b/frontend/apps/rd-console/src/lib/stagingClock.svelte.ts index 793fcdc..f339068 100644 --- a/frontend/apps/rd-console/src/lib/stagingClock.svelte.ts +++ b/frontend/apps/rd-console/src/lib/stagingClock.svelte.ts @@ -13,12 +13,21 @@ * negative past zero (over-time), never auto-advances. * • anything else / no heat → reset to `secs` (idle; the next Staged re-arms it). * - * Like the race clock this is the v1 *approximate* timer: on a late join (already Staged when this - * mounts) it counts from "now", not the real stage time. Sharing the phase-effect shape keeps the - * eventual server-authoritative fix (#62 follow-up) in one family. + * **Server-anchored** (#62 family): the countdown anchors to the live state's `staged_at` — the + * server's `Scheduled → Staged` instant — read against `nowMs()` (the offset-corrected + * `session.serverNowMs()`), so every console (and a reload / late join) reads the SAME window. + * The old per-mount `Date.now()` anchor made each console count its own staging window (the RD + * read overtime while a fresh console read 5:00), and a late-resolving round config silently + * re-anchored a running countdown. Before the server anchor lands (a tick-old stream) it falls + * back to the mount-time anchor, converging on the next push. * * Usage (inside a component, so the `$effect` has an owner): - * const staging = useStagingClock(() => phase, () => round?.staging_timer_secs ?? 300); + * const staging = useStagingClock( + * () => phase, + * () => round?.staging_timer_secs ?? 300, + * () => live?.staged_at, + * () => session.serverNowMs() + * ); * {staging.remainingMs} // ms left; negative once over-time */ @@ -41,7 +50,9 @@ const TICK_MS = 250; */ export function useStagingClock( getPhase: () => string | undefined, - getSecs: () => number + getSecs: () => number, + getStagedAtMicros: () => number | null | undefined = () => undefined, + nowMs: () => number = () => Date.now() ): StagingClockState { let remainingMs = $state(0); @@ -49,11 +60,15 @@ export function useStagingClock( const phase = getPhase(); const totalMs = Math.max(0, Math.round(getSecs() * 1000)); if (phase === 'Staged') { - const startedAt = Date.now(); + // Anchor to the SERVER's staging instant when the stream carries it (µs → ms); fall back + // to the observation instant only until it lands. Both are read against the SAME clock + // (`nowMs()` — offset-corrected server time), the standing skew rule. + const stagedAtMicros = getStagedAtMicros(); + const anchorMs = stagedAtMicros != null ? stagedAtMicros / 1000 : nowMs(); const advance = () => { // Count DOWN from the full window; allow it to pass below zero (over-time) so the RD // sees a field that has overstayed its staging slot. - remainingMs = totalMs - (Date.now() - startedAt); + remainingMs = totalMs - (nowMs() - anchorMs); }; advance(); const id = setInterval(advance, TICK_MS); diff --git a/frontend/apps/rd-console/src/screens/EventAudit.svelte b/frontend/apps/rd-console/src/screens/EventAudit.svelte index f42a0c9..8aee3c6 100644 --- a/frontend/apps/rd-console/src/screens/EventAudit.svelte +++ b/frontend/apps/rd-console/src/screens/EventAudit.svelte @@ -25,8 +25,7 @@ HeatId, HeatSummary, Pilot, - PilotId, - PilotProgress + PilotId } from '@gridfpv/types'; import { toast } from '@gridfpv/components'; import { @@ -115,16 +114,26 @@ // ── Friendly names (the shared resolvers) ─────────────────────────────────────────────────── // Event-wide, so the channel map is the UNION of every heat's frequency assignment and the - // explicit bindings come from the live stream's current heat (the Results pattern) — the bulk - // of refs are roster-seeded pilot ids, which resolve from the directory alone. + // explicit bindings are the union of every heat's DURABLE heat-window bindings + // (`session.heatBindings` — the same durable source Marshaling resolves from). The global live + // stream only carries the CURRENT heat's progress, so a FINISHED node-seeded heat's audit rows + // rendered raw `node-0` (the Results pattern, friendly-names rule). The live current heat's + // progress merges on top so a just-made Register resolves before its heat snapshot lands. const pilotById = $derived(new Map(pilots.map((p) => [p.id, p]))); - const explicitPilotByRef = $derived( - new Map( - (session.liveState?.progress ?? []) - .filter((p): p is PilotProgress & { pilot: PilotId } => p.pilot != null) - .map((p) => [p.competitor, p.pilot]) - ) - ); + $effect(() => { + const ids = heats.map((h) => h.heat); + if (ids.length > 0) void session.ensureHeatBindings(ids); + }); + const explicitPilotByRef = $derived.by(() => { + const map = new Map(); + for (const h of heats) { + const bound = session.heatBindings.get(h.heat); + if (bound) for (const [ref, pid] of bound) map.set(ref, pid); + } + for (const p of session.liveState?.progress ?? []) + if (p.pilot != null) map.set(p.competitor, p.pilot); + return map; + }); const channelByRef = $derived.by(() => { const map = new Map(); for (const h of heats) diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index dd2f466..1b57920 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -53,6 +53,7 @@ WinCondition } from '@gridfpv/types'; import { channelLabel, nodeChannelLabel } from '../lib/channels.js'; + import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { collapseStore } from '../lib/collapse.svelte.js'; import { fieldsForFormat, @@ -99,6 +100,10 @@ // `GET /formats`). Both are open reads, loaded once. The schema backs both the format dropdown and // the guided params editor (which offers only the chosen format's params, each typed by its kind). let classes = $state([]); + // Loaded-flag so a not-yet-resolved class name renders a neutral placeholder ("—") instead of + // the raw id while the directory read is in flight or after it failed (the flash-the-raw-id + // bug — the Results-screen pattern; CLAUDE.md: never print the raw id to the screen). + let classesLoaded = $state(false); let formatSchemas = $state([]); const formats = $derived(formatSchemas.map((s) => s.name)); // The standard channel catalog (race redesign Slice 4b): resolves a heat's assigned raw-MHz @@ -150,13 +155,16 @@ eventClassIds.map((id) => classes.find((c) => c.id === id)).filter((c): c is Class => !!c) ); - const className = (id: ClassId): string => classes.find((c) => c.id === id)?.name ?? id; - const roundLabel = (id: RoundId): string => rounds.find((r) => r.id === id)?.label ?? id; + // A class/round id → its friendly name; never the raw id. A neutral placeholder shows while the + // class directory loads (or after a failed read), and for an unknown id (CLAUDE.md). + const className = (id: ClassId): string => + classesLoaded ? (classes.find((c) => c.id === id)?.name ?? '—') : '—'; + const roundLabel = (id: RoundId): string => rounds.find((r) => r.id === id)?.label ?? '—'; $effect(() => { session .listClasses() - .then((list) => (classes = list)) + .then((list) => ((classes = list), (classesLoaded = true))) .catch((e) => toast.error(e instanceof Error ? e.message : String(e))); session .listFormatSchemas() @@ -209,10 +217,23 @@ void refreshHeats(); }); - // A pilot id maps straight to a `CompetitorRef` of the same string (round_engine.rs); resolve a - // ref to its directory callsign, falling back to the bare ref for an unregistered/free-text one. + // A pilot id maps straight to a `CompetitorRef` of the same string (round_engine.rs). Resolve + // through the SHARED competitor-name resolver (friendly-names rule — never re-derive inline): + // directory callsign first; a `node-{i}` seat falls back to its channel label where a heat's + // channel map is in hand (`heatCallsign`), never the raw seat. const pilotByRef = $derived(new Map(pilots.map((p) => [p.id, p] as const))); - const callsign = (ref: CompetitorRef): string => pilotByRef.get(ref)?.callsign ?? ref; + const callsign = $derived.by<(ref: CompetitorRef) => string>(() => + createCompetitorNameResolver({ pilotById: pilotByRef, explicitPilotByRef: new Map() }) + ); + /** The heat-scoped resolver: same rule plus the heat's channel map, so an open-practice + * lineup's `node-{i}` seats read as their channel label ("Raceband R1 · 5658"). */ + function heatCallsign(channels: Map): (ref: CompetitorRef) => string { + return createCompetitorNameResolver({ + pilotById: pilotByRef, + explicitPilotByRef: new Map(), + channelByRef: channels + }); + } const heatsByRound = (id: RoundId): HeatSummary[] => heats.filter((h) => h.round === id); @@ -290,14 +311,19 @@ // Each round can show a compact ordered ranking (`session.roundRanking`) — the cross-round seeding // source a later round draws `FromRanking` from. - // The expanded-standings round, its loaded ranking, and the in-flight load. Toggling reloads, so a - // freshly-scored heat re-aggregates the ranking. + // The expanded-standings round, its loaded ranking, and the in-flight load. The ranking is + // (re-)fetched by the effect below whenever the expanded round changes OR the stream advances — + // the old fetch-once-on-expand went stale the moment another heat finalized (a correction, a + // finalize from Marshaling) while the panel stayed open. let standingsRound = $state(undefined); let standingsRows = $state([]); let standingsLoading = $state(false); let standingsError = $state(undefined); + // Latest-wins guard (non-reactive): a slower earlier response must not overwrite a newer one + // (round flipped, or a fresher stream-tick re-fetch already landed). + let standingsSeq = 0; - async function toggleStandings(round: RoundDef) { + function toggleStandings(round: RoundDef) { if (standingsRound === round.id) { standingsRound = undefined; return; @@ -305,17 +331,34 @@ standingsRound = round.id; standingsRows = []; standingsError = undefined; - standingsLoading = true; - try { - standingsRows = await session.roundRanking(round.id); - } catch (e) { - // An unscored / unscorable round 400s — surface it inline rather than as a row list. - standingsError = e instanceof Error ? e.message : String(e); - } finally { - standingsLoading = false; - } } + // Fetch while a round's standings are expanded, re-keyed off the stream cursor so a fresh + // finalize/correction re-aggregates the ranking live. Keeps the last good rows on a re-fetch + // (the loading state only shows for an EMPTY panel, so the open list never flashes away). + $effect(() => { + const rid = standingsRound; + void session.protocolState; + const seq = ++standingsSeq; + if (!rid) return; + standingsLoading = true; + session + .roundRanking(rid) + .then((rows) => { + if (seq !== standingsSeq) return; + standingsRows = rows; + standingsError = undefined; + }) + .catch((e) => { + // An unscored / unscorable round 400s — surface it inline rather than as a row list. + if (seq !== standingsSeq) return; + standingsError = e instanceof Error ? e.message : String(e); + }) + .finally(() => { + if (seq === standingsSeq) standingsLoading = false; + }); + }); + // --- Manual heat build (replaces the retired NewHeat free-text form) --------------------------- // Pick a round, then select from that round's **eligible class members** (real roster pilots, no // typed names) → schedule a heat tagged with the round + its single class. The heat id is @@ -474,6 +517,12 @@ // single selection (the common bracket-from-one-qual case) is just a one-element set. let seedSources = $state>(new Set()); let seedTopN = $state(8); + // A seeding this form can't model (`FromHeatWinners` bracket advancement, `FromRankingRange`, + // `Combine`), captured verbatim when editing such a round. The server replaces the round + // WHOLESALE on update, so sending the form's roster/ranking approximation silently rewrote a + // bracket level's "winners of Semifinal" seeding to FromRoster — a grace-window tweak destroyed + // the bracket chain. When set, the seeding controls lock and submit round-trips it unchanged. + let editPreservedSeeding = $state(undefined); // The chosen format's params, as a `key → value` map (Rounds form redesign item 4): every param // the format declares is shown inline as a proper labeled field, seeded from its schema default // (or the edited round's stored value). On a format switch the map is re-seeded to the new @@ -648,6 +697,7 @@ seedKind = 'FromRoster'; seedSources = new Set(); seedTopN = 8; + editPreservedSeeding = undefined; selectedNodes = new Set(); paramValues = {}; pointsTable = [...DEFAULT_POINTS_TABLE]; @@ -719,9 +769,12 @@ seedKind = 'FromRoster'; selectedNodes = new Set(seed.AllChannels.channels); } else { - // FromHeatWinners (bracket-level advancement, #217) — generated by advancing a bracket, not - // manually edited in this form; show as roster-like (the bracket flow drives its seeding). + // FromHeatWinners (bracket-level advancement, #217) / FromRankingRange / Combine — seedings + // this form doesn't model. Preserve the ORIGINAL verbatim and lock the seeding controls: + // `update_round` replaces the round wholesale, so sending the form's approximation would + // silently rewrite a bracket level's advancement chain to FromRoster. seedKind = 'FromRoster'; + editPreservedSeeding = seed; } // Heat-lifecycle config (Slice 3): staging timer (split mm:ss), the randomized start window @@ -790,6 +843,9 @@ } function buildSeeding(): SeedingRule { + // Editing a round whose seeding this form can't model: round-trip the original verbatim + // (the seeding controls are locked in that state) so the update never rewrites it. + if (editPreservedSeeding !== undefined) return editPreservedSeeding; if (seedKind === 'FromRanking' && seedSources.size > 0) { // Serialize the multi-select in a stable order: the order the source rounds are defined on the // event (so the same selection always produces the same `source_rounds`, independent of click @@ -1182,7 +1238,7 @@ standingsRows.every((r) => r.position === standingsRows[0].position)}

      Standings

      - {#if standingsLoading} + {#if standingsLoading && standingsRows.length === 0}

      Loading standings…

      {:else if standingsError}

      @@ -1252,6 +1308,7 @@

        {#each heatsByRound(round.id) as h (h.heat)} {@const channels = channelByRef(h)} + {@const lineupName = heatCallsign(channels)}
      1. @@ -1265,7 +1322,7 @@ {#each h.lineup as ref, i (ref)} - {callsign(ref)} + {lineupName(ref)} {channels.get(ref) ?? '—'} @@ -1441,19 +1498,31 @@ ranking. The FromRanking source-rounds multi-select + top-N reveals for the ranking case; several source rounds are aggregated best-per-pilot (issue #51). --> {#if fields.seeding} - - - + {#if editPreservedSeeding !== undefined} + + +

        + This round's seeding (bracket advancement) isn't editable here — it will be preserved + unchanged. +

        +
        + {:else} + + + + {/if} - {#if seedKind === 'FromRanking'} + {#if editPreservedSeeding === undefined && seedKind === 'FromRanking'}
        phase, - () => stagingSecs + () => stagingSecs, + // Server-anchored (#62 family): every console counts the SAME staging window, from the + // server's Staged instant — not each console's own mount time. + () => live?.staged_at, + () => session.serverNowMs() ); // ── Auto-official countdown (marshaling Slice 5) ───────────────────────────────────────────── diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index d05a400..a04fe8a 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -229,7 +229,10 @@ const add = (progress: readonly PilotProgress[] | undefined): void => { for (const p of progress ?? []) if (p.pilot != null) map.set(p.competitor, p.pilot); }; - add(session.liveState?.progress); // fallback: the global stream (only when it IS this heat) + // Fallback: the global stream — but ONLY when its current heat IS the marshaled heat. Node-seat + // refs (`node-0`) are reused across heats, so merging the live heat's bindings while marshaling + // a DIFFERENT heat captioned this heat's laps with the other heat's pilots (worse than raw). + if (session.liveState?.current_heat === heat) add(session.liveState?.progress); // Authoritative: the marshaled heat's durable binding — but only once the heat-scope fold is for // THIS heat (a stale fold from a just-deselected heat could re-bind a reused `node-0` ref wrong). if (session.heatLiveState?.current_heat === heat) add(session.heatLiveState?.progress); @@ -298,8 +301,38 @@ }); // ── Inline corrections on the SELECTED lap ── - // Edit-time / split / insert-after take a time input (seconds, source clock). - let editSeconds = $state(0); + // Edit-time / split / insert-after take a time input (seconds, source clock). The entered value + // must be a POSITIVE number: an empty/zeroed input reads as 0, and sending it (`AdjustLap at: 0`) + // re-times the pass to the race start and wrecks the whole lap chain. The buttons disable on an + // invalid value (with the explaining title) and the handlers refuse with a toast as a backstop — + // never a silent send of 0. (An emptied number input binds `null`, hence the typeof check.) + let editSeconds = $state(0); + const editSecondsValid = $derived(typeof editSeconds === 'number' && editSeconds > 0); + const editSecondsTitle = 'Enter a positive time (s) first'; + /** The validated time input (µs), or `undefined` — toasting the refusal (visible feedback). */ + function editTimeMicros(): number | undefined { + const seconds = editSeconds; + if (typeof seconds !== 'number' || !(seconds > 0)) { + toast.error('Enter a positive time (seconds) first.'); + return undefined; + } + return secondsToSourceTime(seconds); + } + + // One shared in-flight guard for the correction / ruling / lifecycle submits (the `committing` + // pattern): every correction APPENDS a ruling, so a double-clicked Apply lands the penalty + // TWICE. Each handler runs under this — a click while a previous send is still in flight + // no-ops — and the buttons disable on it so the double-submit can't even be attempted. + let busy = $state(false); + async function submitCorrection(run: () => Promise): Promise { + if (busy) return; + busy = true; + try { + await run(); + } finally { + busy = false; + } + } async function afterCorrection(): Promise { // The send already recorded any error; on success the stream cursor advances and the @@ -308,38 +341,46 @@ if (heat) await session.refreshMarshaling(heat); } - async function doVoidSelected(): Promise { - if (!selected) return; - const target: LogRef = selected.lap.end_ref; - const ack = await session.send(voidDetectionCommand(target)); - if (ack.ok) { - selected = null; - await afterCorrection(); - } + function doVoidSelected(): Promise { + return submitCorrection(async () => { + if (!selected) return; + const target: LogRef = selected.lap.end_ref; + const ack = await session.send(voidDetectionCommand(target)); + if (ack.ok) { + selected = null; + await afterCorrection(); + } + }); } - async function doSplitSelected(): Promise { - if (!selected) return; - const ack = await session.send( - splitLapCommand(selected.lap.end_ref, secondsToSourceTime(editSeconds)) - ); - if (ack.ok) await afterCorrection(); + function doSplitSelected(): Promise { + return submitCorrection(async () => { + if (!selected) return; + const at = editTimeMicros(); + if (at === undefined) return; + const ack = await session.send(splitLapCommand(selected.lap.end_ref, at)); + if (ack.ok) await afterCorrection(); + }); } - async function doEditTimeSelected(): Promise { - if (!selected) return; - const ack = await session.send( - adjustLapCommand(selected.lap.end_ref, secondsToSourceTime(editSeconds)) - ); - if (ack.ok) await afterCorrection(); + function doEditTimeSelected(): Promise { + return submitCorrection(async () => { + if (!selected) return; + const at = editTimeMicros(); + if (at === undefined) return; + const ack = await session.send(adjustLapCommand(selected.lap.end_ref, at)); + if (ack.ok) await afterCorrection(); + }); } - async function doInsertAfterSelected(): Promise { - if (!selected || !heat) return; - const ack = await session.send( - insertLapCommand(adapter, selected.competitor, secondsToSourceTime(editSeconds), heat) - ); - if (ack.ok) await afterCorrection(); + function doInsertAfterSelected(): Promise { + return submitCorrection(async () => { + if (!selected || !heat) return; + const at = editTimeMicros(); + if (at === undefined) return; + const ack = await session.send(insertLapCommand(adapter, selected.competitor, at, heat)); + if (ack.ok) await afterCorrection(); + }); } // ── Add a brand-new lap (not an edit of an existing one) ── @@ -354,56 +395,75 @@ // session may control; the Director re-checks). /** Add a lap for a competitor at an exact source-clock time (µs) — the graph's button path. */ - async function insertLap(competitor: CompetitorRef, at: number): Promise { - if (!canControl || resultLocked || !heat) return; - const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at), heat)); - if (ack.ok) await afterCorrection(); + function insertLap(competitor: CompetitorRef, at: number): Promise { + return submitCorrection(async () => { + if (!canControl || resultLocked || !heat) return; + const ack = await session.send(insertLapCommand(adapter, competitor, Math.round(at), heat)); + if (ack.ok) await afterCorrection(); + }); } // The explicit per-competitor "Add lap" control: a typed time in seconds (source clock), so an RD // running a sim heat (no graph) can still add a lap, including to a competitor with no laps yet. let addLapTarget = $state(''); - let addLapSeconds = $state(0); + let addLapSeconds = $state(0); async function doAddLapAtTime(): Promise { - if (!canControl || !addLapTarget) return; + if (!canControl || !addLapTarget || typeof addLapSeconds !== 'number') return; await insertLap(addLapTarget, secondsToSourceTime(addLapSeconds)); } // Throw out the selected lap from the SCORED count — distinct from "Remove (void)": the lap stays // a real lap, it just no longer counts (marshaling.html §3.3). Targets the lap's end pass. - async function doThrowOutSelected(): Promise { - if (!selected) return; - const ack = await session.send(throwOutLapCommand(selected.lap.end_ref)); - if (ack.ok) { - selected = null; - await afterCorrection(); - } + function doThrowOutSelected(): Promise { + return submitCorrection(async () => { + if (!selected) return; + const ack = await session.send(throwOutLapCommand(selected.lap.end_ref)); + if (ack.ok) { + selected = null; + await afterCorrection(); + } + }); } // ── Per-competitor rulings ── let penaltyTarget = $state(''); // 'dq' = disqualify (status), 'time' = added time (per-heat), 'points' = standings deduction. let penaltyKind = $state<'dq' | 'time' | 'points'>('dq'); - let penaltySeconds = $state(2); - let penaltyPoints = $state(1); + // The amount inputs bind `null` when emptied, so they're validated (positive) before any send. + let penaltySeconds = $state(2); + let penaltyPoints = $state(1); let dqReason = $state(''); - async function doPenalty(): Promise { - if (!heat || !penaltyTarget) return; - // A non-positive time "penalty" would credit time / log a no-op ruling — refuse to send. - if (penaltyKind === 'time' && !(penaltySeconds > 0)) return; - let ack; - if (penaltyKind === 'points') { - // Points affect SEASON/EVENT standings, not the per-heat lap result. - ack = await session.send(deductPointsCommand(heat, penaltyTarget, penaltyPoints)); - } else { - const penalty = - penaltyKind === 'dq' ? disqualifyPenalty(dqReason) : timeAddedPenalty(penaltySeconds); - ack = await session.send(applyPenaltyCommand(heat, penaltyTarget, penalty)); - } - if (ack.ok) { - dqReason = ''; - await afterCorrection(); - } + function doPenalty(): Promise { + return submitCorrection(async () => { + if (!heat || !penaltyTarget) return; + // An invalid amount is REFUSED with visible feedback — never a silent no-op (the old early + // return read as "it worked"): a non-positive time "penalty" would credit time, and a + // 0-point DeductPoints would append a no-op ruling to the record. + const target = penaltyTarget; + let ack; + if (penaltyKind === 'points') { + const points = typeof penaltyPoints === 'number' ? Math.round(penaltyPoints) : NaN; + if (!(points > 0)) { + toast.error('Enter a positive whole number of points to deduct.'); + return; + } + // Points affect SEASON/EVENT standings, not the per-heat lap result. + ack = await session.send(deductPointsCommand(heat, target, points)); + } else if (penaltyKind === 'time') { + const seconds = penaltySeconds; + if (typeof seconds !== 'number' || !(seconds > 0)) { + toast.error('Enter a positive number of seconds for a time penalty.'); + return; + } + ack = await session.send(applyPenaltyCommand(heat, target, timeAddedPenalty(seconds))); + } else { + ack = await session.send(applyPenaltyCommand(heat, target, disqualifyPenalty(dqReason))); + } + if (ack.ok) { + dqReason = ''; + await afterCorrection(); + } + }); } // Reverse any prior reversible ruling — a penalty, a lap throw-out, a protest resolution, or a @@ -414,30 +474,38 @@ 'ProtestResolved', 'HeatVoided' ]; + // Already-reversed rulings are excluded — offering them again invited a double + // ReverseRuling on the same target (a confusing rejection at best). const reversibleRulings = $derived( - (audit ?? []).filter((e) => REVERSIBLE_KINDS.includes(e.kind)) + (audit ?? []).filter( + (e) => REVERSIBLE_KINDS.includes(e.kind) && !reversedRulingTargets.has(e.at_ref) + ) ); let reverseTargetRef = $state(''); - async function doReverse(): Promise { - if (reverseTargetRef === '') return; - const ack = await session.send(reverseRulingCommand(reverseTargetRef as LogRef)); - if (ack.ok) { - reverseTargetRef = ''; - await afterCorrection(); - } + function doReverse(): Promise { + return submitCorrection(async () => { + if (reverseTargetRef === '') return; + const ack = await session.send(reverseRulingCommand(reverseTargetRef as LogRef)); + if (ack.ok) { + reverseTargetRef = ''; + await afterCorrection(); + } + }); } // ── Protests (file → resolve) ── let protestTarget = $state(''); let protestNote = $state(''); - async function doFileProtest(): Promise { - if (!heat || !protestTarget || protestNote.trim() === '') return; - const ack = await session.send(fileProtestCommand(heat, protestTarget, protestNote.trim())); - if (ack.ok) { - protestNote = ''; - protestTarget = ''; - await afterCorrection(); - } + function doFileProtest(): Promise { + return submitCorrection(async () => { + if (!heat || !protestTarget || protestNote.trim() === '') return; + const ack = await session.send(fileProtestCommand(heat, protestTarget, protestNote.trim())); + if (ack.ok) { + protestNote = ''; + protestTarget = ''; + await afterCorrection(); + } + }); } // Filed protests are resolvable by their log offset (the audit entry's `at_ref`). const filedProtests = $derived((audit ?? []).filter((e) => e.kind === 'ProtestFiled')); @@ -470,46 +538,58 @@ }); let resolveProtestRef = $state(''); let protestOutcome = $state('Upheld'); - async function doResolveProtest(): Promise { - if (resolveProtestRef === '') return; - const ack = await session.send( - resolveProtestCommand(resolveProtestRef as LogRef, protestOutcome) - ); - if (ack.ok) { - resolveProtestRef = ''; - await afterCorrection(); - } + function doResolveProtest(): Promise { + return submitCorrection(async () => { + if (resolveProtestRef === '') return; + const ack = await session.send( + resolveProtestCommand(resolveProtestRef as LogRef, protestOutcome) + ); + if (ack.ok) { + resolveProtestRef = ''; + await afterCorrection(); + } + }); } - async function doVoidHeat(): Promise { - if (!heat) return; - const ack = await session.send(voidHeatCommand(heat)); - if (ack.ok) await afterCorrection(); + function doVoidHeat(): Promise { + return submitCorrection(async () => { + if (!heat) return; + const ack = await session.send(voidHeatCommand(heat)); + if (ack.ok) await afterCorrection(); + }); } // Result-lifecycle transitions on the MARSHALED heat (B). These act on `heat` (the picker's heat), // never Race Control's current heat — marshaling issues no SetCurrentHeat. Same append→re-fold path // as every correction, so the badge + buttons update via afterCorrection. - async function doFinalize(): Promise { - if (!heat || openProtestCount > 0) return; - const ack = await session.send(commandForAction('Finalize', heat)); - if (ack.ok) await afterCorrection(); + function doFinalize(): Promise { + return submitCorrection(async () => { + if (!heat || openProtestCount > 0) return; + const ack = await session.send(commandForAction('Finalize', heat)); + if (ack.ok) await afterCorrection(); + }); } - async function doRevert(): Promise { - if (!heat) return; - const ack = await session.send(commandForAction('Revert', heat)); - if (ack.ok) await afterCorrection(); + function doRevert(): Promise { + return submitCorrection(async () => { + if (!heat) return; + const ack = await session.send(commandForAction('Revert', heat)); + if (ack.ok) await afterCorrection(); + }); } - // Competitors that can be acted on: those in the lap list, else the live lineup. + // Competitors that can be acted on: those in the lap list, else THE MARSHALED HEAT's own + // scheduled lineup (the heats directory). Never the global live stream's `active_pilots` — + // that is whichever heat happens to be running NOW, and marshaling frequently pins a + // different heat: the old fallback offered the live heat's pilots in the ruling/protest/ + // add-lap dropdowns, letting the RD record a DQ against a pilot who never flew this heat. // DE-DUPLICATED by ref: the same competitor can appear under TWO adapters in one heat // (a mid-heat source failover — or historically a re-raced heat before the current-run // window fix), and duplicate refs crashed every keyed {#each} over this list. const competitors = $derived( laps && laps.competitors.length > 0 ? [...new Set(laps.competitors.map((c) => c.competitor.competitor))] - : (session.liveState?.active_pilots ?? []) + : (heats.find((h) => h.heat === heat)?.lineup ?? []) ); // Marshal one pilot at a time (declutter): a dropdown picks the pilot, and the graph + lap list @@ -928,7 +1008,9 @@

        This result is official — Revert it to make corrections. Protests may still be filed.

        - Revert → Unofficial + Revert → Unofficial
        {:else} @@ -941,7 +1023,7 @@ {/if}
        - - - + - Edit time + @@ -1001,7 +1098,7 @@ aria-label="Add-lap time" /> -
        @@ -1028,8 +1125,9 @@ {#if penaltyKind === 'time'} + time (improving the competitor), so the input floors at 0.1s and doPenalty + REFUSES a non-positive/empty amount with a visible toast (never a silent + no-op send). --> {/if} -
        @@ -1078,7 +1176,7 @@
        @@ -1110,7 +1208,8 @@ File protest
      @@ -1134,7 +1233,7 @@
      @@ -1154,7 +1253,7 @@ type="button" class="finalize" onclick={doFinalize} - disabled={!heat || openProtestCount > 0} + disabled={!heat || openProtestCount > 0 || busy} title={openProtestCount > 0 ? `Resolve ${openProtestCount} open protest(s) first` : undefined}>Finalize → Official Void the heat

      Throws out the whole heat — it will not count.

      - + Void heat diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index 0946e4a..688ea15 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -27,7 +27,6 @@ HeatSummary, Pilot, PilotId, - PilotProgress, RankEntry, RoundDef, RoundId, @@ -102,16 +101,28 @@ // A ref resolves to: (1) an explicit Register binding's callsign, (2) the ref-as-pilot-id callsign // (the common roster-seeded heat), (3) an open-practice `node-{i}` seat's channel label, else (4) // the bare handle. Results aggregates across heats, so the channel map is the UNION of every heat's - // frequency assignment, and the explicit bindings come from the live stream's current heat - // (best-effort — the bulk of refs are roster-seeded or node seats, which resolve without it). + // frequency assignment, and the explicit bindings are the union of every heat's DURABLE + // heat-window bindings (`session.heatBindings`, the Marshaling source) — the global live stream + // only carries the CURRENT heat's progress, so a FINISHED node-seeded heat's seats rendered raw + // `node-0` in the placements + JSON export. The live current heat's progress merges on top so a + // just-made Register resolves before its heat-window snapshot lands. const pilotById = $derived(new Map(pilots.map((p) => [p.id, p]))); - const explicitPilotByRef = $derived( - new Map( - (session?.liveState?.progress ?? []) - .filter((p): p is PilotProgress & { pilot: PilotId } => p.pilot != null) - .map((p) => [p.competitor, p.pilot]) - ) - ); + $effect(() => { + if (!session) return; + const ids = heats.map((h) => h.heat); + if (ids.length > 0) void session.ensureHeatBindings(ids); + }); + const explicitPilotByRef = $derived.by(() => { + const map = new Map(); + if (!session) return map; + for (const h of heats) { + const bound = session.heatBindings.get(h.heat); + if (bound) for (const [ref, pid] of bound) map.set(ref, pid); + } + for (const p of session.liveState?.progress ?? []) + if (p.pilot != null) map.set(p.competitor, p.pilot); + return map; + }); const channelByRef = $derived.by(() => { const map = new Map(); for (const h of heats) @@ -147,20 +158,28 @@ reloadNonce += 1; } + // A heats-load FAILURE must be visible (#340): the old swallow-into-`[]` silently blanked the + // phase default + the channel-label map with no hint anything was wrong. Keep the last good + // list, toast once on the transition into the error state, and offer the explicit retry. + let heatsError = $state(false); async function refreshHeats() { if (!session) return; try { heats = await session.listHeats(); + heatsError = false; } catch { - heats = []; + if (!heatsError) toast.error('Couldn’t load the heats list — showing the last good data.'); + heatsError = true; } finally { heatsLoaded = true; } } $effect(() => { if (!session) return; - // Touch the protocol state so a freshly scheduled/scored heat re-reads the list. + // Touch the protocol state so a freshly scheduled/scored heat re-reads the list, and the + // retry nonce so "Try again" re-runs a failed read. void session.protocolState; + void reloadNonce; void refreshHeats(); }); @@ -223,11 +242,16 @@ // A load FAILURE is distinct from a genuinely-empty round: track it so the view can show a // "Couldn't load — retry" state instead of the misleading "nothing scored yet" empty state (P1-5). let rankingError = $state(false); + // Latest-wins guard (non-reactive): flipping the view selector re-runs the effect, but a SLOWER + // earlier response could land after the newer one and leave the wrong table rendered until the + // next stream tick. Only the newest fetch (matching sequence stamp) may assign. + let rankingSeq = 0; $effect(() => { if (!session) return; const rid = rankingRoundId; void session.liveState; void reloadNonce; + const seq = ++rankingSeq; if (!rid) { rankingRows = []; return; @@ -236,9 +260,19 @@ rankingError = false; session .roundRanking(rid) - .then((rows) => ((rankingRows = rows), (rankingError = false))) - .catch(() => ((rankingRows = []), (rankingError = true))) - .finally(() => (rankingLoading = false)); + .then((rows) => { + if (seq !== rankingSeq) return; + rankingRows = rows; + rankingError = false; + }) + .catch(() => { + if (seq !== rankingSeq) return; + rankingRows = []; + rankingError = true; + }) + .finally(() => { + if (seq === rankingSeq) rankingLoading = false; + }); }); // --- Time-trial round standings (Best lap + the win-condition metric) ------------------------- @@ -252,11 +286,14 @@ let roundStandingRows = $state([]); let roundStandingsLoading = $state(false); let roundStandingsError = $state(false); + // Latest-wins guard — see `rankingSeq`. + let roundStandingsSeq = 0; $effect(() => { if (!session) return; const rid = timedQualRoundId; void session.liveState; void reloadNonce; + const seq = ++roundStandingsSeq; if (!rid) { roundStandingRows = []; return; @@ -265,9 +302,19 @@ roundStandingsError = false; session .roundStandings(rid) - .then((rows) => ((roundStandingRows = rows), (roundStandingsError = false))) - .catch(() => ((roundStandingRows = []), (roundStandingsError = true))) - .finally(() => (roundStandingsLoading = false)); + .then((rows) => { + if (seq !== roundStandingsSeq) return; + roundStandingRows = rows; + roundStandingsError = false; + }) + .catch(() => { + if (seq !== roundStandingsSeq) return; + roundStandingRows = []; + roundStandingsError = true; + }) + .finally(() => { + if (seq === roundStandingsSeq) roundStandingsLoading = false; + }); }); // The win-condition metric column for the time-trial table: its header (or `undefined` when the @@ -296,11 +343,14 @@ let classRows = $state([]); let classLoading = $state(false); let classError = $state(false); + // Latest-wins guard — see `rankingSeq`. + let classSeq = 0; $effect(() => { if (!session) return; const cls = selectedClassId; void session.liveState; void reloadNonce; + const seq = ++classSeq; if (cls === '') { classRows = []; return; @@ -309,9 +359,19 @@ classError = false; session .classStandings(cls) - .then((s) => ((classRows = s.standings), (classError = false))) - .catch(() => ((classRows = []), (classError = true))) - .finally(() => (classLoading = false)); + .then((s) => { + if (seq !== classSeq) return; + classRows = s.standings; + classError = false; + }) + .catch(() => { + if (seq !== classSeq) return; + classRows = []; + classError = true; + }) + .finally(() => { + if (seq === classSeq) classLoading = false; + }); }); const roundLabelFor = (id: RoundId): string => rounds.find((r) => r.id === id)?.label ?? '—'; @@ -341,6 +401,14 @@ {#if session} + {#if heatsError} + + + {/if} { await waitFor(() => expect(rows()).toHaveLength(4)); }); + it('resolves a FINISHED node-seeded heat’s rows from the durable heat-window binding', async () => { + // A node-seeded heat: the entry's competitor is the raw `node-0` seat, the heat is FINISHED, + // and the global live stream is on a DIFFERENT heat — so its progress can't resolve the seat + // (the regression: the row rendered raw "node-0"). The durable `node-0 → Maverick` bind lives + // in the heat's own `?projection=live` fold, pulled + cached via `session.ensureHeatBindings`. + const NODE_ENTRY: EventAuditEntry[] = [ + { + heat: 'q1-heat', + kind: 'PenaltyApplied', + at: 1_700_000_200_000_000, + at_ref: 20, + competitor: 'node-0', + summary: 'DQ applied' + } + ]; + renderAudit({ + eventAuditImpl: vi.fn(async () => NODE_ENTRY), + live: { current_heat: 'q2-heat', phase: 'Running' }, + heatFetches: { + 'q1-heat': { + live: { + current_heat: 'q1-heat', + phase: 'Final', + progress: [{ competitor: 'node-0', pilot: 'maverick-4d9rp8', laps_completed: 3 }] + } + } + } + }); + + await waitFor(() => expect(rows()).toHaveLength(1)); + // The pilot chip resolves to the bound callsign — never the raw seat (CLAUDE.md). + await waitFor(() => + expect(within(rows()[0]).getByRole('button', { name: 'Maverick' })).toBeInTheDocument() + ); + expect(screen.queryByText(/node-0/)).not.toBeInTheDocument(); + }); + it('is a pure read — renders identically for a read-only session (no gated controls)', async () => { renderAudit({ role: 'readonly' }); await waitFor(() => expect(rows()).toHaveLength(4)); diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index 0e5cb8c..f4823bb 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -130,6 +130,45 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(within(roundsCard).getByText('1')).toBeInTheDocument(); }); + it('shows a neutral placeholder — never the raw class id — while the class directory loads', async () => { + // The directory read HANGS (resolved manually below): the round's class chip must read '—' + // while it is in flight, not flash the raw 'c1' (the Results-screen pattern; CLAUDE.md). + let resolveClasses!: (list: Class[]) => void; + const { session } = makeTestSession({ + ...baseImpls(), + listClassesImpl: vi.fn(() => new Promise((res) => (resolveClasses = res))), + event: EVENT + }); + render(EventRounds, { session }); + + const roundsCard = screen.getByRole('heading', { name: 'Rounds' }).closest('section')!; + await within(roundsCard).findByText('Qualifying R1'); + expect(within(roundsCard).queryByText('c1')).toBeNull(); + expect(within(roundsCard).getAllByText('—').length).toBeGreaterThan(0); + + // The directory lands → the friendly name replaces the placeholder. + resolveClasses([OPEN, SPEC]); + await within(roundsCard).findByText('Open'); + expect(within(roundsCard).queryByText('c1')).toBeNull(); + }); + + it('sticks to the placeholder — never the raw class id — after a failed directory read', async () => { + const { session } = makeTestSession({ + ...baseImpls(), + listClassesImpl: vi.fn(async () => { + throw new Error('GET /classes failed: HTTP 500'); + }), + event: EVENT + }); + render(EventRounds, { session }); + + const roundsCard = screen.getByRole('heading', { name: 'Rounds' }).closest('section')!; + await within(roundsCard).findByText('Qualifying R1'); + // The read failed (the toast surfaces it) — the chip stays neutral rather than raw. + await waitFor(() => expect(within(roundsCard).queryByText('c1')).toBeNull()); + expect(within(roundsCard).getAllByText('—').length).toBeGreaterThan(0); + }); + it('adds a round via createRound and reflects it immediately', async () => { const impls = baseImpls(); const created: RoundDef = { @@ -1502,6 +1541,43 @@ describe('EventRounds (per-round standings — Slice 5/6b)', () => { expect(within(panel).queryByText('AceOne')).toBeNull(); }); + it("re-fetches an EXPANDED round's standings when the stream advances (no stale panel)", async () => { + // The old fetch-once-on-expand went stale the moment another heat finalized while the panel + // stayed open; the fetch is now keyed off the stream cursor too. + let rows = [ + { competitor: 'p1', position: 1 }, + { competitor: 'p2', position: 2 } + ]; + const roundRankingImpl = vi.fn(async () => rows); + const { session, pushLive } = makeTestSession({ + ...baseHeatsImpls(), + roundRankingImpl, + event: EVENT_WITH_MEMBERS + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Standings' })); + const panel = (await screen.findByLabelText(/Standings for Qualifying R1/i)) as HTMLElement; + await waitFor(() => expect(within(panel).getAllByRole('listitem')[0]).toHaveTextContent('AceOne')); + const callsBefore = roundRankingImpl.mock.calls.length; + + // A heat finalizes elsewhere → a stream envelope lands; the OPEN panel must re-aggregate. + rows = [ + { competitor: 'p2', position: 1 }, + { competitor: 'p1', position: 2 } + ]; + pushLive({ current_heat: 'r1-h9', phase: 'Unofficial' }); + await waitFor(() => expect(roundRankingImpl.mock.calls.length).toBeGreaterThan(callsBefore)); + // The fresh order renders (Bolt leads now) — the last good rows stood in while it loaded + // (no "Loading standings…" flash over an already-open list). + await waitFor(() => { + const items = within( + screen.getByLabelText(/Standings for Qualifying R1/i) as HTMLElement + ).getAllByRole('listitem'); + expect(items[0]).toHaveTextContent('Bolt'); + }); + }); + it('surfaces an inline note when a round has no ranking yet (unscored 400s)', async () => { const roundRankingImpl = vi.fn(async () => { throw new Error('GET …/ranking failed: HTTP 400'); diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index be9aeaf..c1f327a 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -3,12 +3,14 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/svelt import { fireEvent } from '@testing-library/dom'; import type { AuditEntry, + CommandAck, EventMeta, HeatSummary, LapList, LiveRaceState, RoundDef } from '@gridfpv/types'; +import { toasts } from '@gridfpv/components'; import Marshaling from '../src/screens/Marshaling.svelte'; import { makeTestSession } from './support.js'; import { @@ -487,13 +489,105 @@ describe('Marshaling (Slice 3)', () => { FileProtest: { heat: 'heat-1', competitor: 'BOB', note: 'contact on lap 2' } }); - // Resolving (e.g. denying) one needs no Revert either. + // Resolving (e.g. denying) one needs no Revert either. (Wait for the file-protest submit to + // settle first — the double-submit guard holds every correction button while one is in + // flight, so the resolve click would otherwise be a legitimate no-op.) await fireEvent.change(screen.getByLabelText('Resolve protest'), { target: { value: '22' } }); await fireEvent.change(screen.getByLabelText('Protest outcome'), { target: { value: 'Denied' } }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Resolve protest' })).toBeEnabled() + ); await fireEvent.click(screen.getByRole('button', { name: 'Resolve protest' })); - expect(sendSpy).toHaveBeenCalledWith({ ResolveProtest: { target: 22, outcome: 'Denied' } }); + await waitFor(() => + expect(sendSpy).toHaveBeenCalledWith({ ResolveProtest: { target: 22, outcome: 'Denied' } }) + ); + }); + }); + + // ── Correction hardening: double-submit guard + amount validation (visible refusals) ───────── + describe('correction hardening (double-submit + invalid amounts)', () => { + it('a double-clicked Apply lands the penalty ONCE (the double-submit guard)', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); + // A slow Director: the first send hangs until released — the window a double-click hits. + let release!: (ack: CommandAck) => void; + sendSpy.mockImplementationOnce(() => new Promise((res) => (release = res))); + render(Marshaling, { session }); + + await fireEvent.change(screen.getByLabelText('Ruling competitor'), { + target: { value: 'BOB' } + }); + const apply = screen.getByRole('button', { name: 'Apply' }); + await fireEvent.click(apply); + // While the first send is in flight the button disables AND the handler no-ops — the + // second click of a double-click must not stack a second penalty. + expect(apply).toBeDisabled(); + await fireEvent.click(apply); + + release({ ok: true }); + await waitFor(() => expect(apply).toBeEnabled()); + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + it('requires a positive time for Split / Edit time / Insert after (never sends at=0)', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); + render(Marshaling, { session }); + + // Select a lap with the time input still at its 0 default: the time-based corrections stay + // DISABLED (with the explaining title) — an `AdjustLap at: 0` wrecks the whole lap chain. + await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*41\.000/ })); + for (const name of ['Split', 'Edit time', 'Insert after']) { + const button = screen.getByRole('button', { name }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'Enter a positive time (s) first'); + } + await fireEvent.click(screen.getByRole('button', { name: 'Edit time' })); + expect(sendSpy).not.toHaveBeenCalled(); + + // An EMPTIED input (binds null) is refused the same way. + await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '' } }); + expect(screen.getByRole('button', { name: 'Edit time' })).toBeDisabled(); + + // A positive time enables them and the command carries it. + await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '40' } }); + expect(screen.getByRole('button', { name: 'Edit time' })).toBeEnabled(); + await fireEvent.click(screen.getByRole('button', { name: 'Edit time' })); + expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 12, at: 40_000_000 } }); + }); + + it('refuses an empty/zero penalty amount with a visible toast — never a silent no-op', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); + toasts.clear(); + render(Marshaling, { session }); + await fireEvent.change(screen.getByLabelText('Ruling competitor'), { + target: { value: 'BOB' } + }); + + // A time penalty with a CLEARED amount: refused + explained; nothing sent. + await fireEvent.change(screen.getByLabelText('Penalty kind'), { target: { value: 'time' } }); + await fireEvent.input(screen.getByLabelText('Seconds'), { target: { value: '' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(sendSpy).not.toHaveBeenCalled(); + expect(toasts.items.some((t) => /positive number of seconds/.test(t.message))).toBe(true); + + // A ZERO points deduction: a 0-point DeductPoints is a no-op ruling — refused + explained. + await fireEvent.change(screen.getByLabelText('Penalty kind'), { + target: { value: 'points' } + }); + await fireEvent.input(screen.getByLabelText('Points to deduct'), { target: { value: '0' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(sendSpy).not.toHaveBeenCalled(); + expect(toasts.items.some((t) => /whole number of points/.test(t.message))).toBe(true); + + // A valid amount still goes through. + await fireEvent.input(screen.getByLabelText('Points to deduct'), { target: { value: '4' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await waitFor(() => + expect(sendSpy).toHaveBeenCalledWith({ + DeductPoints: { heat: 'heat-1', competitor: 'BOB', points: 4 } + }) + ); }); }); diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts index 29a54aa..8e7fc2c 100644 --- a/frontend/apps/rd-console/tests/ResultsScreen.test.ts +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -366,6 +366,133 @@ describe('Results — time-trial round standings (Best lap + win-condition metri }); }); +describe('Results — durable per-heat name resolution (the raw node-0 fix)', () => { + it('resolves a FINISHED node-seeded heat’s seats from the heat-window bindings, never raw', async () => { + // A node-seeded heat: the ranking rows carry the raw `node-0` seat ref, the heat is FINISHED, + // and the global live stream is on a DIFFERENT heat (so its progress can't resolve it — the + // regression). The durable bind (`node-0 → p1`) lives in the heat's own `?projection=live` + // fold, which `session.ensureHeatBindings` pulls + caches for the screen's resolver. + const NODE_HEAT: HeatSummary = { + heat: 'r1-h1', + lineup: ['node-0'], + round: 'r1', + class: 'c1', + frequencies: [], + phase: 'Final', + is_current: false + }; + const { session } = makeTestSession({ + event: { ...EVENT, rounds: [QUAL] }, + live: { current_heat: 'other-heat', phase: 'Running' }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl: vi.fn(async () => [NODE_HEAT]), + roundRankingImpl: vi.fn(async () => [{ competitor: 'node-0', position: 1 }]), + classStandingsImpl: vi.fn(async () => STANDINGS), + heatFetches: { + 'r1-h1': { + live: { + current_heat: 'r1-h1', + phase: 'Final', + progress: [{ competitor: 'node-0', pilot: 'p1', laps_completed: 3 }] + } + } + } + }); + render(Results, { session }); + + const table = (await screen.findByLabelText(/Qualifying standings/i)) as HTMLElement; + // The seat resolves to the bound pilot's callsign — never the raw `node-0` (CLAUDE.md). + await waitFor(() => expect(within(table).getByText('AceOne')).toBeInTheDocument()); + expect(within(table).queryByText('node-0')).not.toBeInTheDocument(); + expect(within(table).queryByText('Node 1')).not.toBeInTheDocument(); + }); +}); + +describe('Results — fetch races + heats-read failure (#340)', () => { + it('latest wins: a SLOWER earlier round fetch cannot overwrite the newer view', async () => { + // r1's ranking read hangs (resolved manually below); b1's resolves immediately. Start on r1, + // flip to b1, then let r1's stale response land — the b1 table must stand. + let resolveQual!: (rows: RankEntry[]) => void; + const racingRankingImpl = vi.fn( + (_b: string, _e: string, roundId: string): Promise => + roundId === 'r1' + ? new Promise((res) => (resolveQual = res)) + : Promise.resolve([ + { competitor: 'p1', position: 1 }, + { competitor: 'p2', position: 2 } + ]) + ); + const { session } = makeTestSession({ + event: { ...EVENT, rounds: [QUAL, BRACKET_FINAL] }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + // Only r1 has scored → the default view is round:r1 (its fetch is the slow one). + listHeatsImpl: vi.fn(async () => [QUAL_HEAT, { ...FINAL_HEAT, phase: 'Scheduled' as const }]), + roundRankingImpl: racingRankingImpl, + classStandingsImpl: vi.fn(async () => STANDINGS) + }); + render(Results, { session }); + + const select = (await screen.findByLabelText('Results view')) as HTMLSelectElement; + await waitFor(() => expect(select.value).toBe('round:r1')); + // Flip to the bracket final while r1's read is still in flight. + await fireEvent.change(select, { target: { value: 'round:b1' } }); + const table = (await screen.findByLabelText(/Pro — Final standings/i)) as HTMLElement; + await waitFor(() => expect(within(table).getByText('AceOne')).toBeInTheDocument()); + + // The STALE r1 response lands late (Bolt first) — without the latest-wins guard it replaced + // the rendered rows, leaving Qualifying's order under the "Pro — Final" header. + resolveQual([ + { competitor: 'p2', position: 1 }, + { competitor: 'p1', position: 2 } + ]); + await waitFor(() => { + const rows = within(screen.getByLabelText(/Pro — Final standings/i)) + .getAllByRole('row') + .slice(1); + expect(within(rows[0]).getByText('AceOne')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Bolt')).toBeInTheDocument(); + }); + }); + + it('keeps the last good heats list on a failed re-read, with a visible retry (#340)', async () => { + // First read succeeds; every later read fails until `heal` flips. The old code swallowed the + // failure into `heats = []`, silently blanking the phase default + channel labels. + let heal = false; + let calls = 0; + const listHeatsImpl = vi.fn(async () => { + calls += 1; + if (calls > 1 && !heal) throw new Error('GET /events/e1/heats failed: HTTP 500'); + return [QUAL_HEAT]; + }); + const { session, pushLive } = makeTestSession({ + event: { ...EVENT, rounds: [QUAL] }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl, + roundRankingImpl: rankingImpl, + classStandingsImpl: vi.fn(async () => STANDINGS) + }); + render(Results, { session }); + const select = (await screen.findByLabelText('Results view')) as HTMLSelectElement; + await waitFor(() => expect(select.value).toBe('round:r1')); + + // A stream tick re-reads the heats list — this read FAILS. + pushLive({ current_heat: 'r1-h1', phase: 'Unofficial' }); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn't load the heats list/); + // The last good list stands: the round view (derived off the heats) keeps rendering. + expect(select.value).toBe('round:r1'); + expect(screen.getByLabelText(/Qualifying standings/i)).toBeInTheDocument(); + + // Retry with the read healthy → the error state clears. + heal = true; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + }); +}); + describe('Results — event-level projections (kept from #56)', () => { it('renders a ranking from typed fixtures', () => { render(Results, { heatResult, standings }); diff --git a/frontend/apps/rd-console/tests/session.svelte.test.ts b/frontend/apps/rd-console/tests/session.svelte.test.ts index e28b853..b6e574b 100644 --- a/frontend/apps/rd-console/tests/session.svelte.test.ts +++ b/frontend/apps/rd-console/tests/session.svelte.test.ts @@ -12,7 +12,7 @@ import type { } from '@gridfpv/types'; import { Session } from '../src/lib/session.svelte.js'; import type { ControlClient, createControlClient } from '../src/lib/control.js'; -import { liveRunning, okAck, failAck } from './fixtures.js'; +import { heatResult, liveRunning, okAck, failAck } from './fixtures.js'; /** * Per-test overrides for the `Session` constructor's injected impls. Derived from the @@ -1066,4 +1066,162 @@ describe('Session', () => { }); }); }); + + // ── Auth-failure detection matches the real HTTP status, never digits inside a message ────── + describe('auth-failure status matching', () => { + it('does NOT prompt on a 500 whose message merely contains "401" (an event id)', async () => { + // The status is 500 — "401" appears only inside the event id. The old whole-message + // \b(401|403)\b scan matched it and opened the token dialog on a plain server error. + const deleteEventImpl = vi.fn(async () => { + throw new Error('DELETE /events/evt-401 failed: HTTP 500'); + }); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + const session = new Session({ deleteEventImpl, autoRestore: false }); + session.setTokenProvider(tokenProvider); + + await expect(session.deleteEvent('evt-401')).rejects.toThrow(/500/); + expect(tokenProvider).not.toHaveBeenCalled(); + expect(deleteEventImpl).toHaveBeenCalledOnce(); + }); + + it('prompts + retries on an error carrying the HTTP status STRUCTURALLY (status: 403)', async () => { + let calls = 0; + const deleteEventImpl = vi.fn(async () => { + calls += 1; + if (calls === 1) throw Object.assign(new Error('Forbidden'), { status: 403 }); + return undefined as unknown as void; + }); + const tokenProvider = vi.fn(async () => 'lazy-tok'); + const session = new Session({ deleteEventImpl, autoRestore: false }); + session.setTokenProvider(tokenProvider); + + const ok = await session.deleteEvent('evt-a'); + expect(tokenProvider).toHaveBeenCalledOnce(); + expect(deleteEventImpl).toHaveBeenCalledTimes(2); + expect(ok).toBe(true); + }); + }); + + // ── heatResult lifecycle: a scored result must never outlive the heat it describes ────────── + describe('heatResult staleness (clear on heat change / Revert)', () => { + /** Serve `?projection=result` with the fixture result; everything else fails (inert). */ + function stubResultFetch() { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (/\/snapshot\/heat\/[^/?]+\?projection=result$/.test(url)) { + return { + ok: true, + json: async () => ({ body: { HeatResult: heatResult } }) + } as unknown as Response; + } + return { ok: false, json: async () => ({}) } as unknown as Response; + }); + } + + function resultSession() { + const { connect, push } = mockConnect(connecting); + const control = { baseUrl: 'http://d.local', sendCommand: vi.fn(async () => okAck) }; + const session = new Session({ + connectImpl: connect, + controlFactory: () => control, + autoRestore: false + }); + session.selectEvent(PRACTICE); + return { session, push }; + } + + it('clears heatResult once the live current heat moves on (stale-export fix)', async () => { + const { session, push } = resultSession(); + const fetchSpy = stubResultFetch(); + await session.fetchHeatResult('heat-1'); + expect(session.heatResult).toBeDefined(); + + // The same heat still current → the result stands. + push({ + body: { LiveRaceState: { ...liveRunning, current_heat: 'heat-1' } }, + cursor: 2, + status: 'live', + error: undefined + }); + expect(session.heatResult).toBeDefined(); + + // The current heat moves to the NEXT heat → the stored result no longer describes it; + // leaving it set embedded the previous heat's result in the Results JSON export. + push({ + body: { LiveRaceState: { ...liveRunning, current_heat: 'heat-2' } }, + cursor: 3, + status: 'live', + error: undefined + }); + expect(session.heatResult).toBeUndefined(); + fetchSpy.mockRestore(); + }); + + it('clears heatResult when ITS heat is Reverted — not on a Revert of another heat', async () => { + const { session } = resultSession(); + const fetchSpy = stubResultFetch(); + await session.fetchHeatResult('heat-1'); + expect(session.heatResult).toBeDefined(); + + // Reverting a DIFFERENT heat leaves this result standing… + await session.send({ Revert: { heat: 'heat-9' } }); + expect(session.heatResult).toBeDefined(); + // …but Reverting the result's own heat re-opens it: no scored result stands. + await session.send({ Revert: { heat: 'heat-1' } }); + expect(session.heatResult).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + + // ── Durable per-heat bindings (the Results/Audit friendly-name source) ─────────────────────── + describe('ensureHeatBindings (durable per-heat registration bindings)', () => { + it('fetches each heat-window fold once, caches the ref→pilot map, and retries failures', async () => { + const { connect } = mockConnect(connecting); + const control = { baseUrl: 'http://d.local', sendCommand: vi.fn(async () => okAck) }; + const session = new Session({ + connectImpl: connect, + controlFactory: () => control, + baseUrl: 'http://d.local', + autoRestore: false + }); + session.selectEvent(PRACTICE); + + // h1's heat-window fold carries the durable `node-0 → p1` bind; h2's read fails. + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + const m = /\/snapshot\/heat\/([^/?]+)\?projection=live$/.exec(url); + if (m && m[1] === 'h1') { + const live = { + current_heat: 'h1', + phase: 'Unofficial', + progress: [{ competitor: 'node-0', pilot: 'p1', laps_completed: 2 }] + }; + return { + ok: true, + json: async () => ({ body: { LiveRaceState: live } }) + } as unknown as Response; + } + return { ok: false, json: async () => ({}) } as unknown as Response; + }); + const liveFetchUrls = () => + fetchSpy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('projection=live')); + + await session.ensureHeatBindings(['h1', 'h2']); + expect(session.heatBindings.get('h1')?.get('node-0')).toBe('p1'); + // h2's read failed → stays uncached (the resolver just falls back for its refs). + expect(session.heatBindings.has('h2')).toBe(false); + + // A second ensure re-fetches ONLY the still-missing heat — h1 is served from the cache. + const before = liveFetchUrls().length; + await session.ensureHeatBindings(['h1', 'h2']); + const fresh = liveFetchUrls().slice(before); + expect(fresh.some((u) => u.includes('/h1?'))).toBe(false); + expect(fresh.some((u) => u.includes('/h2?'))).toBe(true); + + // The cache is event-scoped: leaving the event drops it. + session.leaveEvent(); + expect(session.heatBindings.size).toBe(0); + fetchSpy.mockRestore(); + }); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index a33cf13..062ff9b 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -48,6 +48,8 @@ import type { Command, CommandAck, EventMeta, + HeatId, + HeatResult, LapList, LiveRaceState, SignalTraceView @@ -152,6 +154,24 @@ export function makeTestSession( heatLive?: LiveRaceState; /** The session role (#80). Defaults to `'rd'`; pass `'readonly'` to assert gating. */ role?: SessionRole; + /** + * Serve heat-scope snapshot reads (`/snapshot/heat/{heat}?projection=…`) from the stubbed + * `fetch`, keyed heat → projection seed. By default every fetch fails (inert — the seeded + * `laps`/`audit`/… values above stand); a test that exercises the session's real heat-scope + * fetch path (e.g. `ensureHeatBindings`' durable per-heat bindings, `fetchHeatResult`) seeds + * the heats it needs here and the stub answers with the wire envelope + * (`{ body: { LiveRaceState: … } }`). Any un-seeded heat/projection still fails. + */ + heatFetches?: Record< + HeatId, + { + live?: LiveRaceState; + result?: HeatResult; + laps?: LapList; + audit?: AuditEntry[]; + signal?: SignalTraceView; + } + >; } & TimerImpls ): TestSession { const ack: CommandAck = opts?.ack ?? { ok: true }; @@ -248,9 +268,36 @@ export function makeTestSession( if (opts?.audit) session.marshalingAudit = opts.audit; if (opts?.signal) session.signalTrace = opts.signal; if (opts?.heatLive) session.heatLiveState = opts.heatLive; + // The wire body key per heat-scope projection (mirrors the session's #fetchHeatProjection). + const bodyKeyOf = (projection: string): string | undefined => + ( + { + live: 'LiveRaceState', + result: 'HeatResult', + laps: 'LapList', + audit: 'MarshalingAudit', + signal: 'SignalTrace' + } as Record + )[projection]; vi.stubGlobal( 'fetch', - vi.fn(async () => ({ ok: false, json: async () => ({}) }) as unknown as Response) + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const m = /\/snapshot\/heat\/([^/?]+)\?projection=(\w+)/.exec(url); + if (m && opts?.heatFetches) { + const heat = decodeURIComponent(m[1]); + const projection = m[2] as 'live' | 'result' | 'laps' | 'audit' | 'signal'; + const seeded = opts.heatFetches[heat]?.[projection]; + const bodyKey = bodyKeyOf(projection); + if (seeded !== undefined && bodyKey !== undefined) { + return { + ok: true, + json: async () => ({ body: { [bodyKey]: seeded } }) + } as unknown as Response; + } + } + return { ok: false, json: async () => ({}) } as unknown as Response; + }) ); const pushLive = (state: LiveRaceState) => diff --git a/frontend/contract/audit.contract.ts b/frontend/contract/audit.contract.ts index 316249e..c656e06 100644 --- a/frontend/contract/audit.contract.ts +++ b/frontend/contract/audit.contract.ts @@ -141,6 +141,11 @@ describe('GET /events/{id}/audit serves the heat-tagged, newest-first event audi expect(trail[1].competitor).toBe(pilotB.id); expect(trail[1].summary).toContain('DQ applied'); expect(trail[0].summary).toContain(`(ref ${voidTarget})`); + // The target-addressed void names no competitor: `AuditEntry.competitor` is an EXPLICIT + // `null` on the wire (`CompetitorRef | null`, not an omitted field) — the shape the + // Audit/Marshaling name-resolvers branch on to skip the callsign prefix. + expect('competitor' in trail[0]).toBe(true); + expect(trail[0].competitor).toBeNull(); }); }); diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 7c11ff0..96d6804 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -28,6 +28,7 @@ import type { FormatSchema, Pilot, RoundDef, + StartProcedure, Timer } from '@gridfpv/types'; @@ -1295,6 +1296,83 @@ describe('race Slice 2a: rounds', () => { expect((gone.body as { code?: string }).code).toBe('UnknownScope'); }); + it('POST /rounds round-trips the start procedure — internally tagged on `mode` (heat-lifecycle Slice 2)', async () => { + const event = (await createEvent('Rounds Start Procedure', TOKEN)).body as EventMeta; + await selectOpen(event.id); + + // Omitted on create → the server defaults a randomized-delay procedure. The wire shape + // is INTERNALLY tagged on `mode` (`{ "mode": "randomized-delay", min_delay_ms, … }`) — + // not externally tagged like SeedingRule/WinCondition — so the tag is pinned explicitly. + const defaulted = ( + await addRound( + event.id, + { + label: 'Defaulted Start', + classes: ['mgp-open'], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap', + time_limit_secs: 60 + }, + TOKEN + ) + ).body as RoundDef; + expect(defaulted.start_procedure.mode).toBe('randomized-delay'); + expect(typeof defaulted.start_procedure.min_delay_ms).toBe('number'); + expect(typeof defaulted.start_procedure.max_delay_ms).toBe('number'); + expect(defaulted.start_procedure.min_delay_ms).toBeLessThanOrEqual( + defaulted.start_procedure.max_delay_ms + ); + + // Explicit: the full randomized-delay variant — window bounds plus the optional start + // tone — round-trips through create byte-for-byte. + const procedure: StartProcedure = { + mode: 'randomized-delay', + min_delay_ms: 1200, + max_delay_ms: 3400, + tone: { hz: 880, ms: 400 } + }; + const created = await addRound( + event.id, + { + label: 'Custom Start', + classes: ['mgp-open'], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap', + time_limit_secs: 60, + start_procedure: procedure + }, + TOKEN + ); + expect(created.status).toBe(200); + const round = created.body as RoundDef; + expect(round.start_procedure).toEqual(procedure); + + // …and survives the persist: the event meta reads the same procedure back. + const meta = (await listEvents()).find((e) => e.id === event.id)!; + const stored = (meta.rounds ?? []).find((r) => r.id === round.id)!; + expect(stored.start_procedure).toEqual(procedure); + + // PUT replaces it wholesale too (the round-editor path). + const replaced: StartProcedure = { + mode: 'randomized-delay', + min_delay_ms: 500, + max_delay_ms: 500 + }; + const updated = await mutateRound(event.id, round.id, 'PUT', { + label: 'Custom Start', + classes: ['mgp-open'], + format: 'timed_qual', + params: {}, + win_condition: 'BestLap', + time_limit_secs: 60, + start_procedure: replaced + }); + expect(updated.status).toBe(200); + expect((updated.body as RoundDef).start_procedure).toEqual(replaced); + }); + it('POST /rounds accepts an open_practice round seeded AllChannels (open-practice format)', async () => { // Open practice (open-practice format, Slice 1): a round is `format: "open_practice"` + // `seeding: AllChannels { channels }` (node indices), with no eligible classes — it is keyed on diff --git a/frontend/contract/snapshot.contract.ts b/frontend/contract/snapshot.contract.ts index 38d8263..0e59193 100644 --- a/frontend/contract/snapshot.contract.ts +++ b/frontend/contract/snapshot.contract.ts @@ -105,6 +105,22 @@ describe('seam 1: snapshot routes are path-scoped', () => { expect(Array.isArray((body as { MarshalingAudit: unknown }).MarshalingAudit)).toBe(true); }); + it('GET /snapshot/heat/{id}?projection=signal → 200 SignalTrace (marshaling Slice 1)', async () => { + const { status, json } = await getSnapshot( + `/events/practice/snapshot/heat/${HEAT}?projection=signal` + ); + expect(status).toBe(200); + const body = (json as { body: object }).body; + expect(Object.keys(body)).toEqual(['SignalTrace']); + // The SignalTraceView top-level shape: `{ competitors: CompetitorTrace[] }`. The sim + // adapter emits no RSSI facts, so the trace is present-but-empty — a 200 with an + // empty competitors array, never an error or a missing field (the signal-as-evidence + // panel renders "no trace captured" off exactly this shape). + const view = (body as { SignalTrace: Record }).SignalTrace; + expect(Object.keys(view)).toEqual(['competitors']); + expect(Array.isArray(view.competitors)).toBe(true); + }); + it('GET /snapshot/class/{event}/{class} → 200 LiveRaceState', async () => { const { status, json } = await getSnapshot('/events/practice/snapshot/class/spring-cup/open'); expect(status).toBe(200); diff --git a/frontend/contract/time.contract.ts b/frontend/contract/time.contract.ts new file mode 100644 index 0000000..176a04e --- /dev/null +++ b/frontend/contract/time.contract.ts @@ -0,0 +1,63 @@ +/** + * `GET /time` contract: the **clock-skew keystone**. + * + * The RD console runs on a separate device from the Director, so every UI clock derives from + * `session.serverNowMs()` — an offset measured against `GET /time` (`Date.now() + offset`), + * never the local `Date.now()` alone. That whole scheme hangs off this one tiny response + * shape: `{ now_micros }`, the server wall clock in **microseconds** since the Unix epoch as + * a plain JSON `number` (the i64 → number wire rule, seam 4). If the field were renamed, + * nested, stringified, or switched to milliseconds, every countdown/race clock in the console + * would silently skew — so the shape is pinned here against the real Director. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type Director } from '../test-harness/director.ts'; +import { startContractDirector } from './harness.ts'; + +let director: Director; + +beforeAll(async () => { + director = await startContractDirector(); +}); + +afterAll(async () => { + await director?.stop(); +}); + +/** `GET /time` → the raw status + parsed JSON body. */ +async function getTime(): Promise<{ status: number; json: unknown }> { + const res = await fetch(`${director.baseUrl}/time`); + let json: unknown; + try { + json = await res.json(); + } catch { + json = undefined; + } + return { status: res.status, json }; +} + +describe('GET /time serves the server wall clock as { now_micros }', () => { + it('answers 200 with exactly { now_micros: number } — an open read, no token', async () => { + const { status, json } = await getTime(); + expect(status).toBe(200); + + // Exactly the one field, a plain JSON number (never bigint/string — seam 4), + // and a safe integer (cursors/times are bounded well below 2^53). + expect(Object.keys(json as object)).toEqual(['now_micros']); + const now = (json as { now_micros: unknown }).now_micros; + expect(typeof now).toBe('number'); + expect(Number.isSafeInteger(now)).toBe(true); + + // MICROseconds since the epoch, i.e. "now": within a minute of this process's own + // clock (both run on this machine). A milliseconds or seconds value would be three + // or six orders of magnitude off and fail this bound. + expect(Math.abs((now as number) - Date.now() * 1000)).toBeLessThan(60_000_000); + }); + + it('is a live clock: a later read serves a later instant', async () => { + const first = ((await getTime()).json as { now_micros: number }).now_micros; + await new Promise((r) => setTimeout(r, 10)); + const second = ((await getTime()).json as { now_micros: number }).now_micros; + expect(second).toBeGreaterThan(first); + }); +}); diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts index d3cee81..58964b6 100644 --- a/frontend/packages/protocol-client/src/client.test.ts +++ b/frontend/packages/protocol-client/src/client.test.ts @@ -129,6 +129,19 @@ const envelope = (sequence: number, phase: HeatPhase): ChangeEnvelope => ({ */ const change = (sequence: number, phase: HeatPhase) => ({ Change: envelope(sequence, phase) }); +/** + * A `Delta` change envelope, wrapped as the wire `StreamMessage`. The per-projection + * delta encodings are deferred (#43), so the client cannot fold one into `body` — + * an in-order delta must fail safe (re-snapshot), never freeze the view silently. + */ +const deltaChange = (sequence: number) => ({ + Change: { + sequence, + projection: 'LiveRaceState', + change: { Delta: { appended: 'lap' } } + } +}); + // Let queued microtasks (the async snapshot fetch) settle. const flush = async (): Promise => { await Promise.resolve(); @@ -188,10 +201,12 @@ describe('ProtocolClient', () => { sockets[0].emit(change(2, 'Armed')); sockets[0].emit(change(3, 'Running')); - // Every envelope applied → body converged. The resume cursor stays the snapshot - // offset (the stream sequence is not a log offset). + // Every envelope applied → body converged. The resume cursor ADVANCES by one per + // applied envelope (5 → 8) — it tracks the last-applied position (each envelope is + // ≥ 1 log append), so a reconnect resumes there rather than replaying from the + // snapshot offset. It is still NOT the stream sequence (a different axis). expect(phaseOf(client.getState().body)).toBe('Running'); - expect(client.getState().cursor).toBe(5); + expect(client.getState().cursor).toBe(8); client.close(); }); @@ -243,11 +258,11 @@ describe('ProtocolClient', () => { expect(req.from).toBe(5); // The fresh subscription restarts the per-stream sequence, so its first envelope - // is accepted and the body converges. The resume cursor stays the re-snapshot - // offset (5). + // is accepted and the body converges. The resume cursor advances past the + // re-snapshot offset with the applied envelope (5 → 6). sockets[1].emit(change(6, 'Unofficial')); expect(phaseOf(client.getState().body)).toBe('Unofficial'); - expect(client.getState().cursor).toBe(5); + expect(client.getState().cursor).toBe(6); client.close(); }); @@ -262,10 +277,16 @@ describe('ProtocolClient', () => { await flush(); sockets[0].open(); + // An applied envelope advances the resume cursor off the snapshot offset (100 → 101)… + sockets[0].emit(change(1, 'Armed')); + expect(client.getState().cursor).toBe(101); + const staleErr: ProtocolError = { code: 'StaleCursor', message: 'cursor too old to replay' }; sockets[0].emit({ ReSnapshotRequired: staleErr }); await flush(); + // …and the stale-cursor fallback re-seeds it wholesale from the fresh snapshot (200): + // re-snapshot remains the authority, whatever the advanced cursor said. expect(calls).toHaveLength(2); expect(client.getState().cursor).toBe(200); expect(sockets).toHaveLength(2); @@ -304,11 +325,12 @@ describe('ProtocolClient', () => { sockets[1].open(); const req = JSON.parse(sockets[1].sent[0]); - // Resume from the snapshot's log offset (0) — NOT the stream sequence (which is a - // different axis). The server replays from there and fresh-value envelopes - // re-converge. (A future enhancement: carry the log offset on envelopes so the - // resume point can advance; until then resume re-replays from the snapshot.) - expect(req.from).toBe(0); + // Resume from the LAST-APPLIED position: the resume cursor advanced by one per + // applied envelope (snapshot offset 0 + 2 applied = 2), so the re-subscribe does + // NOT re-present the original snapshot offset and replay the whole backlog + // through onState (the stale-state flashes), and it cannot age out of the + // retained window (StaleCursor) while envelopes keep applying. + expect(req.from).toBe(2); expect(client.getState().status).toBe('live'); // The resumed subscription restarts the sequence; its first envelope converges. @@ -318,6 +340,61 @@ describe('ProtocolClient', () => { client.close(); }); + it('fails safe on an unhandled Delta envelope: re-snapshot, never a silent freeze', async () => { + // The per-projection delta encodings are deferred (#43): the client cannot fold a + // `Delta` into `body`. Advancing the sequence without the mutation would freeze + // the view while `status` reads 'live' — so an in-order delta must take the same + // re-snapshot path a StaleCursor does. + const { fetch, calls } = mockFetch([ + { cursor: 3, body: liveState('Scheduled') }, // initial snapshot + { cursor: 9, body: liveState('Running') } // re-snapshot forced by the delta + ]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + sockets[0].emit(deltaChange(1)); + await flush(); + + // The delta triggered a re-snapshot: a second fetch, the old socket torn down, + // and the state is the FRESH snapshot's (current), not a frozen 'Scheduled'. + expect(calls).toHaveLength(2); + expect(sockets[0].closed).toBe(true); + expect(phaseOf(client.getState().body)).toBe('Running'); + expect(client.getState().cursor).toBe(9); + + // A fresh socket re-subscribes from the re-snapshot cursor. + expect(sockets).toHaveLength(2); + sockets[1].open(); + expect(JSON.parse(sockets[1].sent[0]).from).toBe(9); + expect(client.getState().status).toBe('live'); + + client.close(); + }); + + it('a re-delivered Delta at/below the applied sequence is a duplicate no-op (no re-snapshot)', async () => { + const { fetch, calls } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); + const { factory, sockets } = mockWsFactory(); + const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory }); + await flush(); + sockets[0].open(); + + sockets[0].emit(change(1, 'Staged')); + sockets[0].emit(change(2, 'Armed')); + // At-least-once redelivery of an already-applied sequence as a Delta: it is deduped + // by sequence BEFORE the unsupported-delta check, so no re-snapshot fires. + sockets[0].emit(deltaChange(2)); + await flush(); + + expect(calls).toHaveLength(1); // no extra snapshot fetch + expect(sockets).toHaveLength(1); // the socket stayed up + expect(phaseOf(client.getState().body)).toBe('Armed'); + expect(client.getState().cursor).toBe(2); // 0 + the two applied envelopes + + client.close(); + }); + it('notifies onState listeners and stops after close', async () => { const { fetch } = mockFetch([{ cursor: 0, body: liveState('Scheduled') }]); const { factory, sockets } = mockWsFactory(); diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 3453aa9..ee5ed2d 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -1155,8 +1155,11 @@ export function connect(options: ConnectOptions): ProtocolClient { // ── Mutable connection state ─────────────────────────────────────────────── let body: ProjectionBody | undefined; - // The snapshot `cursor` is a log offset (protocol.html §2) used ONLY as the `from:` - // resume point — it is not the stream's ordering counter. + // The resume cursor: a log offset (protocol.html §2/§3 "as built") used ONLY as the + // `from:` resume point — it is not the stream's ordering counter. Seeded by each + // snapshot and ADVANCED by one per applied envelope (see `applyEnvelope`), so a + // reconnect resumes from the last-applied position instead of replaying the whole + // backlog from the snapshot's original offset. let cursor: Cursor | undefined; // The per-stream `sequence` axis (protocol.html §3/§9.5): starts at 1 on each // subscription, distinct from `cursor`. Reset to 0 on every (re)subscribe so the @@ -1228,9 +1231,11 @@ export function connect(options: ConnectOptions): ProtocolClient { // ── Apply one ordered change envelope (protocol.html §3) ──────────────────── // - // Returns 'applied', 'duplicate' (already seen — idempotent no-op), or 'gap' - // (missed envelopes → caller must re-snapshot). - function applyEnvelope(env: ChangeEnvelope): 'applied' | 'duplicate' | 'gap' { + // Returns 'applied', 'duplicate' (already seen — idempotent no-op), 'gap' + // (missed envelopes → caller must re-snapshot), or 'unsupported' (a delta this + // client cannot fold → caller must re-snapshot, the same fail-safe as a + // StaleCursor). + function applyEnvelope(env: ChangeEnvelope): 'applied' | 'duplicate' | 'gap' | 'unsupported' { const seq = env.sequence; // Order + dedup against the per-stream `sequence` axis — NOT the snapshot // `cursor` (a log offset). The two are distinct monotonic counters @@ -1244,17 +1249,27 @@ export function connect(options: ConnectOptions): ProtocolClient { if (seq !== streamSeq + 1) return 'gap'; } const change = env.change; - if ('FreshValue' in change) { - body = change.FreshValue; - } else { + if (!('FreshValue' in change)) { // Delta. The per-projection delta encodings are deferred (#43): the wire - // type carries an opaque payload today. We advance the sequence so ordering - // and gap-detection stay correct; once #43 pins the typed deltas, fold them - // into `body` here per ProjectionKind. Until then a delta cannot mutate - // `body`, and a re-snapshot (always correct, §3) reconciles any drift. - void change.Delta; + // type carries an opaque payload this client cannot fold into `body`. + // Advancing the sequence without the mutation would silently FREEZE the + // view while `status` still reads 'live' — so an unhandled delta fails + // safe instead: report it so the caller re-snapshots (always correct, §3), + // exactly the path a StaleCursor takes. Once #43 pins the typed deltas, + // fold them into `body` here per ProjectionKind and return 'applied'. + return 'unsupported'; } + body = change.FreshValue; streamSeq = seq; + // Advance the RESUME cursor alongside the stream. The resume `from` is a log + // offset the wire does not echo per envelope, but every applied envelope + // corresponds to at least one log append past the current cursor, so a +1 + // advance is a conservative (at-or-behind the true offset) tracker. Without + // it a reconnect re-presented the ORIGINAL snapshot's offset and replayed the + // entire backlog through onState — or fell out of the retained window + // (StaleCursor). Any short remainder behind the true offset replays as + // idempotent fresh values, and the re-snapshot path reconciles any drift. + cursor = (cursor ?? 0) + 1; return 'applied'; } @@ -1312,8 +1327,9 @@ export function connect(options: ConnectOptions): ProtocolClient { // `StreamMessage::Change(envelope)` — the common case: unwrap + apply. if (isStreamChange(parsed)) { const result = applyEnvelope(parsed.Change); - if (result === 'gap') { - // Missed envelopes the stream can't replay → re-snapshot and re-subscribe. + if (result === 'gap' || result === 'unsupported') { + // Missed envelopes the stream can't replay, or a delta this client can't + // fold (#43) → re-snapshot and re-subscribe (always correct, §3). await resnapshot(gen); return; } From c17610ca7daa983c43a15233f17e96b258e29f0e Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:49:28 +0000 Subject: [PATCH 335/362] style: rustfmt + prettier over the audit changes Co-Authored-By: Claude Fable 5 --- frontend/apps/rd-console/tests/LiveRaceControl.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 5e80e32..b36947c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -906,8 +906,7 @@ describe('LiveRaceControl', () => { // Two clocks in countdown mode: the big remaining readout + the small companion elapsed. const remainingText = () => screen.getByRole('timer', { name: /^Time remaining/ }).textContent?.trim(); - const elapsedText = () => - screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); + const elapsedText = () => screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); // 1.25s in: remaining = 120s − 1.25s counting DOWN, while the companion counts UP from 0 // (lap times are elapsed-from-zero quantities — the RD reads them off this one). From 17dbacfaa153c94852642e3ff735e6d6afc579d3 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:49:33 +0000 Subject: [PATCH 336/362] style: rustfmt + prettier over the audit changes Co-Authored-By: Claude Fable 5 --- crates/engine/src/timed_qual.rs | 9 +++++++-- crates/server/src/app.rs | 4 +++- crates/server/src/round_engine.rs | 5 +---- frontend/apps/rd-console/tests/LiveRaceControl.test.ts | 3 +-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 720af8a..a39e698 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -107,7 +107,12 @@ impl QualMetric { /// 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 { + fn round_key( + self, + placement_metric: Metric, + laps: u32, + best_lap_micros: Option, + ) -> Option { match (self, placement_metric) { // Best-lap qualifying: the matched metric when the round was scored under // BestLap; otherwise the condition-independent `Placement.best_lap_micros` @@ -633,7 +638,7 @@ mod tests { name, (i as u32) + 1, 3, - Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })) + Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })), ) }) .collect(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 4ce8aa5..3ee81b7 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -103,7 +103,9 @@ use crate::events::{ RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing}; +use crate::live_state::{ + HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, +}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 4f2df27..6d4354a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -3644,10 +3644,7 @@ mod tests { // 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 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 { diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 5e80e32..b36947c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -906,8 +906,7 @@ describe('LiveRaceControl', () => { // Two clocks in countdown mode: the big remaining readout + the small companion elapsed. const remainingText = () => screen.getByRole('timer', { name: /^Time remaining/ }).textContent?.trim(); - const elapsedText = () => - screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); + const elapsedText = () => screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); // 1.25s in: remaining = 120s − 1.25s counting DOWN, while the companion counts UP from 0 // (lap times are elapsed-from-zero quantities — the RD reads them off this one). From 748f6c00229a31449e7d3897c6fc98c3bb6452a9 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:49:38 +0000 Subject: [PATCH 337/362] style: rustfmt + prettier over the audit changes Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 6 ++---- crates/engine/src/timed_qual.rs | 9 +++++++-- crates/server/src/app.rs | 4 +++- crates/server/src/round_engine.rs | 5 +---- frontend/apps/rd-console/tests/LiveRaceControl.test.ts | 3 +-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 1481079..5b315dd 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -640,9 +640,7 @@ pub(crate) async fn run_bridge( // 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:?}" - ); + eprintln!("gridfpv: bridge could not read the event log (will retry): {e:?}"); continue; } }; @@ -2853,7 +2851,7 @@ mod tests { #[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 diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 720af8a..a39e698 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -107,7 +107,12 @@ impl QualMetric { /// 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 { + fn round_key( + self, + placement_metric: Metric, + laps: u32, + best_lap_micros: Option, + ) -> Option { match (self, placement_metric) { // Best-lap qualifying: the matched metric when the round was scored under // BestLap; otherwise the condition-independent `Placement.best_lap_micros` @@ -633,7 +638,7 @@ mod tests { name, (i as u32) + 1, 3, - Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })) + Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })), ) }) .collect(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index a651a8a..bdfd3c9 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -103,7 +103,9 @@ use crate::events::{ RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing}; +use crate::live_state::{ + HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, +}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 4f2df27..6d4354a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -3644,10 +3644,7 @@ mod tests { // 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 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 { diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 5e80e32..b36947c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -906,8 +906,7 @@ describe('LiveRaceControl', () => { // Two clocks in countdown mode: the big remaining readout + the small companion elapsed. const remainingText = () => screen.getByRole('timer', { name: /^Time remaining/ }).textContent?.trim(); - const elapsedText = () => - screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); + const elapsedText = () => screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); // 1.25s in: remaining = 120s − 1.25s counting DOWN, while the companion counts UP from 0 // (lap times are elapsed-from-zero quantities — the RD reads them off this one). From d3f530b61f92cdecabe2523e86968fb6af8007bf Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:49:42 +0000 Subject: [PATCH 338/362] style: rustfmt + prettier over the audit changes Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 6 ++---- crates/engine/src/timed_qual.rs | 9 +++++++-- crates/server/src/app.rs | 4 +++- crates/server/src/events.rs | 15 +++++++++------ crates/server/src/round_engine.rs | 5 +---- .../apps/rd-console/tests/LiveRaceControl.test.ts | 3 +-- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 1481079..5b315dd 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -640,9 +640,7 @@ pub(crate) async fn run_bridge( // 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:?}" - ); + eprintln!("gridfpv: bridge could not read the event log (will retry): {e:?}"); continue; } }; @@ -2853,7 +2851,7 @@ mod tests { #[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 diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 720af8a..a39e698 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -107,7 +107,12 @@ impl QualMetric { /// 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 { + fn round_key( + self, + placement_metric: Metric, + laps: u32, + best_lap_micros: Option, + ) -> Option { match (self, placement_metric) { // Best-lap qualifying: the matched metric when the round was scored under // BestLap; otherwise the condition-independent `Placement.best_lap_micros` @@ -633,7 +638,7 @@ mod tests { name, (i as u32) + 1, 3, - Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })) + Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })), ) }) .collect(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index a651a8a..bdfd3c9 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -103,7 +103,9 @@ use crate::events::{ RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing}; +use crate::live_state::{ + HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, +}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 75b576b..a3aba0d 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -1485,8 +1485,7 @@ impl EventRegistry { 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) - { + if heat_state.is_some_and(|s| s != gridfpv_engine::heat::HeatState::Scheduled) { raced = true; break; } @@ -1511,7 +1510,13 @@ impl EventRegistry { .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 { + 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); @@ -3272,9 +3277,7 @@ mod tests { // 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()); + 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())); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 4f2df27..6d4354a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -3644,10 +3644,7 @@ mod tests { // 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 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 { diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 5e80e32..b36947c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -906,8 +906,7 @@ describe('LiveRaceControl', () => { // Two clocks in countdown mode: the big remaining readout + the small companion elapsed. const remainingText = () => screen.getByRole('timer', { name: /^Time remaining/ }).textContent?.trim(); - const elapsedText = () => - screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); + const elapsedText = () => screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); // 1.25s in: remaining = 120s − 1.25s counting DOWN, while the companion counts UP from 0 // (lap times are elapsed-from-zero quantities — the RD reads them off this one). From 86795e4683c256588595f59e347a937736fece2c Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:49:47 +0000 Subject: [PATCH 339/362] style: rustfmt + prettier over the audit changes Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 6 ++---- crates/engine/src/timed_qual.rs | 9 +++++++-- crates/server/src/app.rs | 4 +++- crates/server/src/events.rs | 15 +++++++++------ crates/server/src/round_engine.rs | 5 +---- .../apps/rd-console/tests/EventRounds.test.ts | 4 +++- .../apps/rd-console/tests/LiveRaceControl.test.ts | 3 +-- frontend/apps/rd-console/tests/support.ts | 4 ++-- 8 files changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 1481079..5b315dd 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -640,9 +640,7 @@ pub(crate) async fn run_bridge( // 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:?}" - ); + eprintln!("gridfpv: bridge could not read the event log (will retry): {e:?}"); continue; } }; @@ -2853,7 +2851,7 @@ mod tests { #[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 diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 720af8a..a39e698 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -107,7 +107,12 @@ impl QualMetric { /// 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 { + fn round_key( + self, + placement_metric: Metric, + laps: u32, + best_lap_micros: Option, + ) -> Option { match (self, placement_metric) { // Best-lap qualifying: the matched metric when the round was scored under // BestLap; otherwise the condition-independent `Placement.best_lap_micros` @@ -633,7 +638,7 @@ mod tests { name, (i as u32) + 1, 3, - Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })) + Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })), ) }) .collect(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index a651a8a..bdfd3c9 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -103,7 +103,9 @@ use crate::events::{ RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; -use crate::live_state::{HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing}; +use crate::live_state::{ + HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, +}; use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; use crate::round_engine; use crate::scope::{ClassId, EventId, PilotId}; diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 75b576b..a3aba0d 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -1485,8 +1485,7 @@ impl EventRegistry { 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) - { + if heat_state.is_some_and(|s| s != gridfpv_engine::heat::HeatState::Scheduled) { raced = true; break; } @@ -1511,7 +1510,13 @@ impl EventRegistry { .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 { + 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); @@ -3272,9 +3277,7 @@ mod tests { // 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()); + 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())); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 4f2df27..6d4354a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -3644,10 +3644,7 @@ mod tests { // 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 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 { diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index f4823bb..f4f9852 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -1558,7 +1558,9 @@ describe('EventRounds (per-round standings — Slice 5/6b)', () => { await fireEvent.click(await screen.findByRole('button', { name: 'Standings' })); const panel = (await screen.findByLabelText(/Standings for Qualifying R1/i)) as HTMLElement; - await waitFor(() => expect(within(panel).getAllByRole('listitem')[0]).toHaveTextContent('AceOne')); + await waitFor(() => + expect(within(panel).getAllByRole('listitem')[0]).toHaveTextContent('AceOne') + ); const callsBefore = roundRankingImpl.mock.calls.length; // A heat finalizes elsewhere → a stream envelope lands; the OPEN panel must re-aggregate. diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 5e80e32..b36947c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -906,8 +906,7 @@ describe('LiveRaceControl', () => { // Two clocks in countdown mode: the big remaining readout + the small companion elapsed. const remainingText = () => screen.getByRole('timer', { name: /^Time remaining/ }).textContent?.trim(); - const elapsedText = () => - screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); + const elapsedText = () => screen.getByRole('timer', { name: /^Elapsed/ }).textContent?.trim(); // 1.25s in: remaining = 120s − 1.25s counting DOWN, while the companion counts UP from 0 // (lap times are elapsed-from-zero quantities — the RD reads them off this one). diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 062ff9b..8b1bab6 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -271,13 +271,13 @@ export function makeTestSession( // The wire body key per heat-scope projection (mirrors the session's #fetchHeatProjection). const bodyKeyOf = (projection: string): string | undefined => ( - { + ({ live: 'LiveRaceState', result: 'HeatResult', laps: 'LapList', audit: 'MarshalingAudit', signal: 'SignalTrace' - } as Record + }) as Record )[projection]; vi.stubGlobal( 'fetch', From 54fd6f8b1d9abdb96c954be5d5f4ae59deb34705 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:55:36 +0000 Subject: [PATCH 340/362] fix(lint): drop dead window wrappers, next_back over last, collapsed match guard Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 8 ++++---- crates/server/src/app.rs | 23 +++-------------------- crates/server/src/live_state.rs | 2 +- 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 528fedd..8bc51b8 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1742,10 +1742,10 @@ fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { // 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() => { - if p.heat.as_ref().is_none_or(|h| h == heat) { - passes.push(p); - } + Event::Pass(p) + if running && p.gate.is_lap_gate() && p.heat.as_ref().is_none_or(|h| h == heat) => + { + passes.push(p) } _ => {} } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 3ee81b7..fac34bb 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1639,16 +1639,9 @@ async fn snapshot_class( /// 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`] 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. -pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { - class_window_offsets(events, class) - .into_iter() - .map(|(_, e)| e) - .collect() -} - -/// [`class_window`] carrying each event's GLOBAL append offset — the class-scope live fold +/// [`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)> { @@ -1937,16 +1930,6 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, window } -/// The heat window as a bare `Vec` — the offset-agnostic view used where global offsets -/// are not needed (live state, results scoring). The marshaling lap/audit folds use -/// [`heat_window_offsets`] instead, so they target the correct global `LogRef`. -pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { - heat_window_offsets(events, heat) - .into_iter() - .map(|(_, e)| e) - .collect() -} - /// 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 diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 51b880e..c1759f2 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -513,7 +513,7 @@ fn heat_staged_at(stored: &[StoredEvent], heat: &HeatId) -> Option { } if h == heat => entry.recorded_at, _ => None, }) - .last() + .next_back() } /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. From 5c55fca1ff19b164bf4c1654632e60c39b25af0c Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:56:00 +0000 Subject: [PATCH 341/362] fix(lint): drop dead window wrappers, next_back over last, collapsed match guard Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 8 ++++---- crates/server/src/app.rs | 23 +++-------------------- crates/server/src/live_state.rs | 2 +- 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 5b315dd..40cb59e 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1891,10 +1891,10 @@ fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { // 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() => { - if p.heat.as_ref().is_none_or(|h| h == heat) { - passes.push(p); - } + Event::Pass(p) + if running && p.gate.is_lap_gate() && p.heat.as_ref().is_none_or(|h| h == heat) => + { + passes.push(p) } _ => {} } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index bdfd3c9..8f25454 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1677,16 +1677,9 @@ async fn snapshot_class( /// 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`] 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. -pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { - class_window_offsets(events, class) - .into_iter() - .map(|(_, e)| e) - .collect() -} - -/// [`class_window`] carrying each event's GLOBAL append offset — the class-scope live fold +/// [`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)> { @@ -1975,16 +1968,6 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, window } -/// The heat window as a bare `Vec` — the offset-agnostic view used where global offsets -/// are not needed (live state, results scoring). The marshaling lap/audit folds use -/// [`heat_window_offsets`] instead, so they target the correct global `LogRef`. -pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { - heat_window_offsets(events, heat) - .into_iter() - .map(|(_, e)| e) - .collect() -} - /// 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 diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 51b880e..c1759f2 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -513,7 +513,7 @@ fn heat_staged_at(stored: &[StoredEvent], heat: &HeatId) -> Option { } if h == heat => entry.recorded_at, _ => None, }) - .last() + .next_back() } /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. From e4624e038def5746c20ab524b39bbb6247deaeb2 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:56:00 +0000 Subject: [PATCH 342/362] fix(lint): drop dead window wrappers, next_back over last, collapsed match guard Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 8 ++++---- crates/server/src/app.rs | 23 +++-------------------- crates/server/src/control_handler.rs | 2 +- crates/server/src/live_state.rs | 2 +- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 5b315dd..40cb59e 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1891,10 +1891,10 @@ fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { // 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() => { - if p.heat.as_ref().is_none_or(|h| h == heat) { - passes.push(p); - } + Event::Pass(p) + if running && p.gate.is_lap_gate() && p.heat.as_ref().is_none_or(|h| h == heat) => + { + passes.push(p) } _ => {} } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index bdfd3c9..8f25454 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1677,16 +1677,9 @@ async fn snapshot_class( /// 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`] 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. -pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { - class_window_offsets(events, class) - .into_iter() - .map(|(_, e)| e) - .collect() -} - -/// [`class_window`] carrying each event's GLOBAL append offset — the class-scope live fold +/// [`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)> { @@ -1975,16 +1968,6 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, window } -/// The heat window as a bare `Vec` — the offset-agnostic view used where global offsets -/// are not needed (live state, results scoring). The marshaling lap/audit folds use -/// [`heat_window_offsets`] instead, so they target the correct global `LogRef`. -pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { - heat_window_offsets(events, heat) - .into_iter() - .map(|(_, e)| e) - .collect() -} - /// 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 diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index f3e9b58..7d1f2d4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -1441,7 +1441,7 @@ pub fn open_protest_count(events: &[Event], heat: &HeatId) -> usize { } if h == heat => Some(i as u64 + 1), _ => None, }) - .last() + .next_back() .unwrap_or(0); // Filed protests for this heat since the latest reset, with no effective resolution. events diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 51b880e..c1759f2 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -513,7 +513,7 @@ fn heat_staged_at(stored: &[StoredEvent], heat: &HeatId) -> Option { } if h == heat => entry.recorded_at, _ => None, }) - .last() + .next_back() } /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. From 8f90ca090eea69bae04a99da01e4365d9c7b2c0b Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:56:01 +0000 Subject: [PATCH 343/362] fix(lint): drop dead window wrappers, next_back over last, collapsed match guard Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 8 ++++---- crates/server/src/app.rs | 23 +++-------------------- crates/server/src/control_handler.rs | 2 +- crates/server/src/live_state.rs | 2 +- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 5b315dd..40cb59e 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1891,10 +1891,10 @@ fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { // 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() => { - if p.heat.as_ref().is_none_or(|h| h == heat) { - passes.push(p); - } + Event::Pass(p) + if running && p.gate.is_lap_gate() && p.heat.as_ref().is_none_or(|h| h == heat) => + { + passes.push(p) } _ => {} } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index bdfd3c9..8f25454 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1677,16 +1677,9 @@ async fn snapshot_class( /// 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`] 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. -pub(crate) fn class_window(events: &[Event], class: &ClassId) -> Vec { - class_window_offsets(events, class) - .into_iter() - .map(|(_, e)| e) - .collect() -} - -/// [`class_window`] carrying each event's GLOBAL append offset — the class-scope live fold +/// [`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)> { @@ -1975,16 +1968,6 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, window } -/// The heat window as a bare `Vec` — the offset-agnostic view used where global offsets -/// are not needed (live state, results scoring). The marshaling lap/audit folds use -/// [`heat_window_offsets`] instead, so they target the correct global `LogRef`. -pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec { - heat_window_offsets(events, heat) - .into_iter() - .map(|(_, e)| e) - .collect() -} - /// 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 diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index f3e9b58..7d1f2d4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -1441,7 +1441,7 @@ pub fn open_protest_count(events: &[Event], heat: &HeatId) -> usize { } if h == heat => Some(i as u64 + 1), _ => None, }) - .last() + .next_back() .unwrap_or(0); // Filed protests for this heat since the latest reset, with no effective resolution. events diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 51b880e..c1759f2 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -513,7 +513,7 @@ fn heat_staged_at(stored: &[StoredEvent], heat: &HeatId) -> Option { } if h == heat => entry.recorded_at, _ => None, }) - .last() + .next_back() } /// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports. From 754d2a637373ad01bd90d24fdb6f4eafd96bf551 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:57:00 +0000 Subject: [PATCH 344/362] fix(lint): drop a duplicated allow attribute Co-Authored-By: Claude Fable 5 --- crates/app/src/source/rotorhazard.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index c097438..507d5e1 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -419,7 +419,6 @@ fn claim_finish_flags(finishing: bool, done: &mut bool, settle_pending: bool) -> /// 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)] -#[allow(clippy::too_many_arguments)] fn drive( url: String, timer_id: TimerId, From 80b9afe0585393f51a5562f738b34e9a408d67c6 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:57:00 +0000 Subject: [PATCH 345/362] fix(lint): drop a duplicated allow attribute Co-Authored-By: Claude Fable 5 --- crates/app/src/source/rotorhazard.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index c097438..507d5e1 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -419,7 +419,6 @@ fn claim_finish_flags(finishing: bool, done: &mut bool, settle_pending: bool) -> /// 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)] -#[allow(clippy::too_many_arguments)] fn drive( url: String, timer_id: TimerId, From ac4ca3a458b65edd71711f89723ef24f497cf913 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 12:57:00 +0000 Subject: [PATCH 346/362] fix(lint): drop a duplicated allow attribute Co-Authored-By: Claude Fable 5 --- crates/app/src/source/rotorhazard.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs index c097438..507d5e1 100644 --- a/crates/app/src/source/rotorhazard.rs +++ b/crates/app/src/source/rotorhazard.rs @@ -419,7 +419,6 @@ fn claim_finish_flags(finishing: bool, done: &mut bool, settle_pending: bool) -> /// 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)] -#[allow(clippy::too_many_arguments)] fn drive( url: String, timer_id: TimerId, From 28f5c54190083ed5448c812d7609d720647a326b Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 16:28:24 +0000 Subject: [PATCH 347/362] feat(marshaling): voids suppress re-detection; lap-list-centric corrections; RSSI zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The void/re-detection split-brain (live repro: Audit Shakedown, Quali, Blaze): the RD removes a valid-crossing-but-not-a-lap detection, and the threshold tuner — which reads only the RSSI trace — keeps proposing the crossing back as 'a lap to add'. The removal record and the tuner now share the same data: - The LapList projection carries each competitor's RD-VOIDED passes (CompetitorLaps::voided — at + the voided pass's own offset), folded from the same corrected-passes fold that drops them from the laps (void-the-void un-records them). The console renders them struck-through in place. - Re-detection SUPPRESSES any detected crossing within match tolerance of a voided instant: never counted as an add, never inserted by a commit, shown as 'voided by you, stays removed' in the preview and the summary. The marshaling surface consolidates around the lap-times box (user-specced): - Tune detection is purely the enter/exit levels + commit; the LAP BOX becomes the live detection readout while actively tuning (kept/added/removed/voided rows) — and only on explicit intent (a drag or typed level), so a trace whose recorded thresholds disagree with the official laps can't hijack the list. - Each lap row carries Remove directly; selecting a row opens an INLINE editor (Split / Edit time / Insert after / Throw out) where the lap is. The separate correction and Add-a-lap panels are gone; adding is an inline control in each competitor's box (and still available when a pilot has no laps at all). - The RSSI graph zooms: wheel at the cursor, +/−/Fit buttons, drag-to-pan while zoomed; the sample downsampling budget follows the visible window so zooming reveals real detail; markers/windows clip to the plot. Co-Authored-By: Claude Fable 5 --- bindings/CompetitorLaps.ts | 12 +- bindings/VoidedPass.ts | 17 + crates/projection/src/lib.rs | 113 ++++- .../apps/rd-console/src/lib/RssiGraph.svelte | 287 ++++++++--- frontend/apps/rd-console/src/lib/redetect.ts | 49 +- .../rd-console/src/screens/Marshaling.svelte | 474 ++++++++++++------ .../rd-console/tests/MarshalingScreen.test.ts | 111 +++- .../apps/rd-console/tests/redetect.test.ts | 46 ++ 8 files changed, 851 insertions(+), 258 deletions(-) create mode 100644 bindings/VoidedPass.ts diff --git a/bindings/CompetitorLaps.ts b/bindings/CompetitorLaps.ts index 8717f70..5f2f0dd 100644 --- a/bindings/CompetitorLaps.ts +++ b/bindings/CompetitorLaps.ts @@ -1,6 +1,7 @@ // 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. @@ -13,4 +14,13 @@ competitor: CompetitorKey, /** * Completed laps, ordered by lap number (1-based, ascending). */ -laps: Array, }; +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/VoidedPass.ts b/bindings/VoidedPass.ts new file mode 100644 index 0000000..2997b90 --- /dev/null +++ b/bindings/VoidedPass.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 { LogRef } from "./LogRef"; +import type { SourceTime } from "./SourceTime"; + +/** + * One RD-voided gate pass, as the lap list records it (see [`CompetitorLaps::voided`]). + */ +export type VoidedPass = { +/** + * Source-clock time (µs) the voided pass was recorded at (re-time applied, like a + * surviving pass — the instant re-detection would re-find it at). + */ +at: SourceTime, +/** + * The voided pass's own global log offset (a stable row identity for the UI). + */ +pass_ref: LogRef, }; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 0d7df35..15e79a8 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -107,6 +107,25 @@ pub struct CompetitorLaps { 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) the voided pass was recorded at (re-time applied, like a + /// surviving pass — the instant re-detection would re-find it at). + pub at: SourceTime, + /// The voided pass's own global log offset (a stable row identity for the UI). + pub pass_ref: LogRef, } impl CompetitorLaps { @@ -287,6 +306,16 @@ where /// 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`] 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<(u64, Pass)>) where I: IntoIterator, { @@ -417,28 +446,29 @@ where } } - // Emit the surviving passes (raw + inserted) with any re-time applied, in offset - // order, each paired with the global offset that addresses it for a future correction; - // callers re-group and re-order them as needed. + // 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<(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); + let sink: &mut Vec<(u64, Pass)> = if is_voided { &mut voided_out } else { &mut out }; match entry { Entry::RawPass(pass) => { let mut p = (*pass).clone(); if let Some(at) = retime.get(offset) { p.at = *at; } - out.push((*offset, p)); + sink.push((*offset, p)); } Entry::Inserted(pass) => { let mut p = pass.clone(); if let Some(at) = retime.get(offset) { p.at = *at; } - out.push((*offset, p)); + sink.push((*offset, p)); } Entry::Split { target, at } => { // Attribute the synthetic mid-lap pass to the competitor whose lap ends at @@ -452,7 +482,7 @@ where _ => None, }; if let Some(src) = src { - out.push(( + sink.push(( *offset, Pass { adapter: src.adapter, @@ -469,7 +499,7 @@ where Entry::Adjusted { .. } | Entry::Voided { .. } => {} } } - out + (out, voided_out) } /// Fold a sequence of `(offset, event)` pairs into the lap-list read model, @@ -491,21 +521,40 @@ where // 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(events); let mut by_competitor: BTreeMap> = BTreeMap::new(); - for (offset, pass) in corrected_passes(events) { + 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, pass) in voided { + by_competitor + .entry(CompetitorKey::from_pass(&pass)) + .or_default(); + voided_by_competitor + .entry(CompetitorKey::from_pass(&pass)) + .or_default() + .push(VoidedPass { + at: pass.at, + pass_ref: LogRef(offset), + }); + } let competitors = by_competitor .into_iter() .map(|(competitor, mut passes)| { 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(); @@ -1422,6 +1471,48 @@ mod marshaling_tests { ); } + #[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 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 voiding_an_insert_removes_the_synthetic_pass() { // A void may target a `LapInserted` (an adjudication) — voiding offset 2 diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index 8228cd5..cec3f39 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -203,16 +203,24 @@ ): string { const n = t.samples.length; if (n === 0) return ''; - const stride = n > MAX_POINTS ? Math.ceil(n / MAX_POINTS) : 1; + // Only the samples inside the (possibly zoomed) span, plus one neighbor each side so the + // line enters/exits the frame — this is what makes zooming reveal detail: the downsampling + // budget is spent on the visible window, not the whole capture. + let lo = 0; + while (lo < n - 1 && sampleTimeOf(t, lo + 1) < span.from) lo++; + let hi = n - 1; + while (hi > 0 && sampleTimeOf(t, hi - 1) > span.to) hi--; + const visible = hi - lo + 1; + const stride = visible > MAX_POINTS ? Math.ceil(visible / MAX_POINTS) : 1; const pts: string[] = []; - for (let i = 0; i < n; i += stride) { + for (let i = lo; i <= hi; i += stride) { pts.push( `${xOf(sampleTimeOf(t, i), span).toFixed(1)},${yOf(t.samples[i], range).toFixed(1)}` ); } - // Always include the last sample so the line reaches the end of the span. + // Always include the last visible sample so the line reaches the end of the span. pts.push( - `${xOf(sampleTimeOf(t, n - 1), span).toFixed(1)},${yOf(t.samples[n - 1], range).toFixed(1)}` + `${xOf(sampleTimeOf(t, hi), span).toFixed(1)},${yOf(t.samples[hi], range).toFixed(1)}` ); return pts.join(' '); } @@ -318,6 +326,106 @@ hover = null; } + // ── Time-axis zoom & pan ────────────────────────────────────────────────────────────────────── + // Wheel over a trace zooms around the cursor's instant; when zoomed, dragging the plot pans and + // the −/+/reset buttons in the caption do the same without a wheel. Pure VIEW state — zooming + // narrows the span every projection already takes, so markers, windows, thresholds, hover and + // the add-lap readout all follow for free. One trace zooms at a time (keyed by competitor). + const MIN_ZOOM_WINDOW_MICROS = 250_000; // never tighter than 0.25s — samples stay meaningful + const WHEEL_ZOOM_FACTOR = 0.8; // one notch in → 80% of the window + let zoom = $state<{ ref: CompetitorRef; from: number; to: number } | null>(null); + + /** The span a trace is DRAWN against: the zoom window (clamped inside the full span), else all. */ + function viewSpanOf( + ref: CompetitorRef, + full: { from: number; to: number } + ): { from: number; to: number } { + if (!zoom || zoom.ref !== ref) return full; + const fullW = full.to - full.from || 1; + const w = Math.min(zoom.to - zoom.from, fullW); + let from = Math.max(full.from, zoom.from); + if (from + w > full.to) from = full.to - w; + return { from, to: from + w }; + } + + /** Set (or clear) the zoom window: a window at/above the full span resets to unzoomed. */ + function setZoom( + ref: CompetitorRef, + full: { from: number; to: number }, + from: number, + width: number + ): void { + const fullW = full.to - full.from || 1; + if (width >= fullW) { + zoom = null; + return; + } + const w = Math.max(Math.min(MIN_ZOOM_WINDOW_MICROS, fullW), width); + let f = Math.max(full.from, Math.min(from, full.to - w)); + zoom = { ref, from: f, to: f + w }; + } + + /** Zoom by `factor` keeping `focusTime` at the same on-screen fraction (wheel-at-cursor). */ + function zoomAt( + ref: CompetitorRef, + full: { from: number; to: number }, + focusTime: number, + factor: number + ): void { + const view = viewSpanOf(ref, full); + const curW = view.to - view.from || 1; + const width = curW * factor; + const frac = Math.min(1, Math.max(0, (focusTime - view.from) / curW)); + setZoom(ref, full, focusTime - frac * width, width); + } + + /** The caption buttons: zoom in/out around the current view's center. */ + function zoomStep(ref: CompetitorRef, full: { from: number; to: number }, factor: number): void { + const view = viewSpanOf(ref, full); + zoomAt(ref, full, (view.from + view.to) / 2, factor); + } + + function onWheel(e: WheelEvent, ct: CompetitorTrace, full: { from: number; to: number }): void { + e.preventDefault(); + const svg = e.currentTarget as SVGSVGElement; + const view = viewSpanOf(ct.competitor.competitor, full); + const x = Math.min(PAD_L + plotW, Math.max(PAD_L, pointerX(e, svg))); + const focus = timeAt(x, view); + zoomAt( + ct.competitor.competitor, + full, + focus, + e.deltaY > 0 ? 1 / WHEEL_ZOOM_FACTOR : WHEEL_ZOOM_FACTOR + ); + } + + // Drag-to-pan while zoomed. Starts on the svg background (the threshold handles stop + // propagation so their drags stay theirs); only a real horizontal move pans, so marker + // clicks still land. + let panning = $state<{ ref: CompetitorRef; lastX: number } | null>(null); + + function startPan(e: PointerEvent, ref: CompetitorRef): void { + if (!zoom || zoom.ref !== ref) return; + panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement) }; + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + } + + function movePan(e: PointerEvent, full: { from: number; to: number }): void { + if (!panning || !zoom || zoom.ref !== panning.ref) return; + const svg = e.currentTarget as SVGSVGElement; + const x = pointerX(e, svg); + const dx = x - panning.lastX; + if (dx === 0) return; + panning = { ...panning, lastX: x }; + const view = viewSpanOf(panning.ref, full); + const dt = (dx / plotW) * (view.to - view.from); + setZoom(panning.ref, full, view.from - dt, view.to - view.from); + } + + function endPan(): void { + panning = null; + } + // There is deliberately NO click-on-the-trace add-lap path: a bare svg click is un-labelled and // misfires — every threshold drag that ends inside the svg makes the browser synthesize a click // on it, and stray clicks land there too, each planting a phantom "Lap inserted" ruling (live @@ -354,6 +462,7 @@ function startThresholdDrag(e: PointerEvent, ct: CompetitorTrace, which: 'enter' | 'exit'): void { if (!onthresholds) return; + e.stopPropagation(); // a handle drag must not also start a zoom pan on the svg beneath dragging = { ref: ct.competitor.competitor, which }; // Keep receiving moves outside the handle while dragging (jsdom has no pointer capture). (e.currentTarget as Element).setPointerCapture?.(e.pointerId); @@ -406,10 +515,12 @@ >
      - {#each trace.competitors as ct (ct.competitor.adapter + '/' + ct.competitor.competitor)} + {#each trace.competitors as ct, traceIndex (ct.competitor.adapter + '/' + ct.competitor.competitor)} {@const ref = ct.competitor.competitor} {@const compLaps = lapsFor(ref)} - {@const span = spanOf(ct, compLaps)} + {@const fullSpan = spanOf(ct, compLaps)} + {@const span = viewSpanOf(ref, fullSpan)} + {@const zoomed = span.from > fullSpan.from || span.to < fullSpan.to} {@const th = effectiveThresholds(ct)} {@const range = valueRange(ct, th.enter, th.exit)} {@const who = nameFor(ref)} @@ -422,6 +533,35 @@ {#if th.enter != null}· enter {th.enter}{/if} {#if th.exit != null}· exit {th.exit}{/if} + {#if ct.samples.length > 0} + + + + + {#if zoomed} + {formatTime(span.from)}–{formatTime(span.to)}s · drag to pan + {/if} + + {/if} {#if ct.samples.length === 0}

      No samples captured for this node.

      @@ -433,78 +573,92 @@ plot, so the SVG stays a non-interactive `role="img"` figure. --> onHover(e, ct, span)} onmouseleave={clearHover} + onwheel={(e: WheelEvent) => onWheel(e, ct, fullSpan)} + onpointerdown={(e: PointerEvent) => startPan(e, ref)} + onpointermove={(e: PointerEvent) => movePan(e, fullSpan)} + onpointerup={endPan} + onpointercancel={endPan} > + + + + + + - - - {#each crossingWindows(ct, th.enter, th.exit) as cw (cw.from)} - {@const x1 = xOf(cw.from, span)} - {@const x2 = xOf(cw.to, span)} - - {/each} + {#each crossingWindows(ct, th.enter, th.exit) as cw (cw.from)} + {@const x1 = xOf(cw.from, span)} + {@const x2 = xOf(cw.to, span)} + + {/each} - - {#if th.enter != null} - {@const y = yOf(th.enter, range)} - - {/if} - {#if th.exit != null} - {@const y = yOf(th.exit, range)} - - {/if} + {#if th.enter != null} + {@const y = yOf(th.enter, range)} + + {/if} + {#if th.exit != null} + {@const y = yOf(th.exit, range)} + + {/if} - - + + - - {#each compLaps as lap (lap.end_ref)} - {@const x = xOf(lap.at, span)} - onselect(ref, lap)} - onkeydown={(e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onselect(ref, lap); - } - }} - > - - - - {lap.number} - - {/each} + + {#each compLaps as lap (lap.end_ref)} + {@const x = xOf(lap.at, span)} + onselect(ref, lap)} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onselect(ref, lap); + } + }} + > + + + + {lap.number} + + {/each} - - {#each pv.added as t (t)} - {@const x = xOf(t, span)} - - {/each} + {#each pv.added as t (t)} + {@const x = xOf(t, span)} + + {/each} + @@ -649,6 +803,25 @@ font-style: italic; color: var(--gf-text-faint); } + .zoom-controls { + display: inline-flex; + align-items: center; + gap: var(--gf-space-1); + margin-left: auto; + } + .zoom-controls button { + min-width: 2rem; + padding: 0.1rem 0.4rem; + font-size: var(--gf-font-size-sm); + } + .zoom-note { + font-size: var(--gf-font-size-2xs); + color: var(--gf-text-muted); + } + .plot.panning { + cursor: grabbing; + } + .trace { margin: 0; padding: var(--gf-space-3); diff --git a/frontend/apps/rd-console/src/lib/redetect.ts b/frontend/apps/rd-console/src/lib/redetect.ts index 8c1b808..046368f 100644 --- a/frontend/apps/rd-console/src/lib/redetect.ts +++ b/frontend/apps/rd-console/src/lib/redetect.ts @@ -39,6 +39,13 @@ export interface PassDiff { added: number[]; /** Official passes the re-detection no longer sees — each becomes a `VoidDetection`. */ removed: OfficialPass[]; + /** + * Re-detected crossings SUPPRESSED because the RD explicitly voided a pass there + * (within tolerance). The RSSI trace genuinely shows the crossing, but the marshal already + * ruled it out — so re-detection must never re-propose it as "a lap to add" (the removal + * record and the tuner share the lap list's `voided` data). Never inserted by a commit. + */ + suppressed: number[]; } /** A preview lap derived from consecutive re-detected passes (see {@link previewLaps}). */ @@ -109,13 +116,24 @@ export function detectPasses(trace: CompetitorTrace, enter: number, exit: number export function diffPasses( current: OfficialPass[], detected: number[], - toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS + toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS, + voidedAt: number[] = [] ): PassDiff { + // FIRST: suppress every detected crossing the RD explicitly voided (within tolerance). + // The trace still shows the crossing — that's exactly why the void must win here, or the + // tuner keeps offering a removed lap back as an add. + const suppressed: number[] = []; + const live: number[] = []; + for (const t of detected) { + if (voidedAt.some((v) => Math.abs(v - t) <= toleranceMicros)) suppressed.push(t); + else live.push(t); + } + // Every candidate pairing within tolerance, nearest first — the greedy order. const candidates: { ci: number; di: number; dist: number }[] = []; for (let ci = 0; ci < current.length; ci++) { - for (let di = 0; di < detected.length; di++) { - const dist = Math.abs(detected[di] - current[ci].at); + for (let di = 0; di < live.length; di++) { + const dist = Math.abs(live[di] - current[ci].at); if (dist <= toleranceMicros) candidates.push({ ci, di, dist }); } } @@ -128,14 +146,15 @@ export function diffPasses( if (matchedCurrent.has(ci) || matchedDetected.has(di)) continue; matchedCurrent.add(ci); matchedDetected.add(di); - kept.push({ at: current[ci].at, ref: current[ci].ref, detectedAt: detected[di] }); + kept.push({ at: current[ci].at, ref: current[ci].ref, detectedAt: live[di] }); } kept.sort((a, b) => a.at - b.at); return { kept, - added: detected.filter((_, di) => !matchedDetected.has(di)), - removed: current.filter((_, ci) => !matchedCurrent.has(ci)) + added: live.filter((_, di) => !matchedDetected.has(di)), + removed: current.filter((_, ci) => !matchedCurrent.has(ci)), + suppressed }; } @@ -178,6 +197,12 @@ export type PreviewRow = at: number; /** The pass's log offset — the `VoidDetection` target a commit sends. */ ref: number; + } + | { + status: 'voided'; + /** Source-clock time (µs) of a detected crossing the RD already voided — shown so the + * story is honest ("the trace sees it; you removed it"), never inserted by a commit. */ + at: number; }; /** @@ -191,17 +216,23 @@ export type PreviewRow = export function previewRows( current: OfficialPass[], detected: number[], - toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS + toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS, + voidedAt: number[] = [] ): PreviewRow[] { - const diff = diffPasses(current, detected, toleranceMicros); + const diff = diffPasses(current, detected, toleranceMicros, voidedAt); const keptDetectedAt = new Set(diff.kept.map((k) => k.detectedAt)); - const rows: PreviewRow[] = previewLaps(detected).map((lap) => ({ + // The lap chain derives from the detected passes MINUS the RD-suppressed crossings — the + // post-commit record will not contain them, so the previewed laps must not either. + const suppressed = new Set(diff.suppressed); + const chain = detected.filter((t) => !suppressed.has(t)); + const rows: PreviewRow[] = previewLaps(chain).map((lap) => ({ status: keptDetectedAt.has(lap.at) ? 'kept' : 'added', number: lap.number, at: lap.at, durationMicros: lap.durationMicros })); for (const pass of diff.removed) rows.push({ status: 'removed', at: pass.at, ref: pass.ref }); + for (const at of diff.suppressed) rows.push({ status: 'voided', at }); // Chronological (stable, so same-instant rows keep lap-then-removed order). rows.sort((a, b) => a.at - b.at); return rows; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index a04fe8a..2f42534 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -67,6 +67,7 @@ } from '../lib/marshaling.js'; import type { ProtestOutcome } from '@gridfpv/types'; import { + DEFAULT_MATCH_TOLERANCE_MICROS, defaultThresholds, detectPasses, diffPasses, @@ -341,18 +342,6 @@ if (heat) await session.refreshMarshaling(heat); } - function doVoidSelected(): Promise { - return submitCorrection(async () => { - if (!selected) return; - const target: LogRef = selected.lap.end_ref; - const ack = await session.send(voidDetectionCommand(target)); - if (ack.ok) { - selected = null; - await afterCorrection(); - } - }); - } - function doSplitSelected(): Promise { return submitCorrection(async () => { if (!selected) return; @@ -403,13 +392,31 @@ }); } - // The explicit per-competitor "Add lap" control: a typed time in seconds (source clock), so an RD - // running a sim heat (no graph) can still add a lap, including to a competitor with no laps yet. - let addLapTarget = $state(''); + // The "Add lap" control lives IN the lap-times box (one per competitor entry): a small + // toggle that opens an inline typed-time row — no separate panel, no competitor picker (the + // box IS the competitor). Works with zero existing laps (sim heats included). + let addLapOpen = $state(false); let addLapSeconds = $state(0); - async function doAddLapAtTime(): Promise { - if (!canControl || !addLapTarget || typeof addLapSeconds !== 'number') return; - await insertLap(addLapTarget, secondsToSourceTime(addLapSeconds)); + const addLapSecondsValid = $derived(typeof addLapSeconds === 'number' && addLapSeconds > 0); + async function doAddLapAtTime(competitor: CompetitorRef): Promise { + if (!canControl || typeof addLapSeconds !== 'number' || !(addLapSeconds > 0)) { + toast.error('Enter a positive time (seconds) first.'); + return; + } + await insertLap(competitor, secondsToSourceTime(addLapSeconds)); + addLapOpen = false; + } + + /** Remove (void) a lap straight from its row — the one-click removal on the lap list. */ + function doVoidLap(competitor: CompetitorRef, lap: Lap): Promise { + return submitCorrection(async () => { + const ack = await session.send(voidDetectionCommand(lap.end_ref)); + if (ack.ok) { + if (selected && selected.competitor === competitor && selected.lap.end_ref === lap.end_ref) + selected = null; + await afterCorrection(); + } + }); } // Throw out the selected lap from the SCORED count — distinct from "Remove (void)": the lap stays @@ -633,6 +640,11 @@ let tuneFor = $state(undefined); let tuneEnter = $state(0); let tuneExit = $state(0); + // Whether the RD has ACTIVELY moved the levels this session (a drag or a typed edit). The + // lap box only switches into re-detection preview on explicit intent — a trace whose + // recorded thresholds happen to disagree with the official laps must not hijack the + // interactive lap list on its own. + let tuneTouched = $state(false); /** The trace's recorded thresholds; an unset trace falls back to a percentile derivation. */ function recordedThresholds(t: CompetitorTrace): { enter: number; exit: number } { @@ -653,6 +665,7 @@ } if (tuneFor === t.competitor.competitor) return; tuneFor = t.competitor.competitor; + tuneTouched = false; const rec = recordedThresholds(t); tuneEnter = rec.enter; tuneExit = rec.exit; @@ -661,6 +674,7 @@ /** The graph's drag handles emit here — two-way with the number inputs. */ function onGraphThresholds(competitor: CompetitorRef, enter: number, exit: number): void { if (competitor !== shownPilot) return; + tuneTouched = true; tuneEnter = enter; tuneExit = exit; } @@ -676,16 +690,49 @@ ? detectPasses(tuneTrace, tuneEnter, tuneExit) : [] ); - const redetectDiff = $derived(diffPasses(officialPasses(shownPilotLaps), detectedPassTimes)); + // The RD's removal record (`CompetitorLaps.voided`) — the lap list and the tuner share this + // data, so a crossing the marshal explicitly voided is SUPPRESSED from re-detection (the + // trace still shows it; without this the tuner kept offering the removed lap back as an add). + const shownVoidedAt = $derived( + (shownLaps?.competitors ?? []).flatMap((c) => (c.voided ?? []).map((v) => v.at)) + ); + const redetectDiff = $derived( + diffPasses( + officialPasses(shownPilotLaps), + detectedPassTimes, + DEFAULT_MATCH_TOLERANCE_MICROS, + shownVoidedAt + ) + ); const redetectDirty = $derived(redetectDiff.added.length > 0 || redetectDiff.removed.length > 0); // The UNIFIED preview: one chronological row list — kept/added laps interleaved with the - // official passes the re-detection drops (redetect.ts `previewRows`), so the whole story reads - // top-down instead of a confusing side-by-side of preview vs official lists. - const previewRowList = $derived(previewRows(officialPasses(shownPilotLaps), detectedPassTimes)); - const previewLapCount = $derived(previewRowList.filter((r) => r.status !== 'removed').length); + // dropped official passes and the RD-voided crossings (which stay removed). Rendered IN the + // lap-times box while tuning, so the lap list itself is the live detection readout. + const previewRowList = $derived( + previewRows( + officialPasses(shownPilotLaps), + detectedPassTimes, + DEFAULT_MATCH_TOLERANCE_MICROS, + shownVoidedAt + ) + ); + const previewLapCount = $derived( + previewRowList.filter((r) => r.status === 'kept' || r.status === 'added').length + ); + /** Whether the lap box is in live-preview mode: the RD actively moved the levels AND the + * re-detection differs from the official record. Never entered passively. */ + const tuningPreview = $derived( + canControl && + tuneTouched && + tuneTrace != null && + tuneFor === shownPilot && + tuneValid && + redetectDirty + ); function doResetThresholds(): void { if (!tuneTrace) return; + tuneTouched = false; const rec = recordedThresholds(tuneTrace); tuneEnter = rec.enter; tuneExit = rec.exit; @@ -732,6 +779,7 @@ } await afterCorrection(); if (ok) { + tuneTouched = false; const plus = added.length === 1 ? '+1 pass' : `+${added.length} passes`; toast.success( `Committed re-detection for ${competitorName(key.competitor)}: ${plus}, −${removed.length} removed` @@ -899,12 +947,19 @@ type="number" step="1" bind:value={tuneEnter} + oninput={() => (tuneTouched = true)} aria-label="Enter threshold" />

      Would be {previewLapCount} lap{previewLapCount === 1 ? '' : 's'} - (+{redetectDiff.added.length} added, −{redetectDiff.removed.length} removed) + (+{redetectDiff.added.length} added, −{redetectDiff.removed.length} removed{redetectDiff + .suppressed.length > 0 + ? `, ${redetectDiff.suppressed.length} voided by you stay removed` + : ''})

      - {#if redetectDirty} - -
        - {#each previewRowList as row (row.status === 'removed' ? `x${row.ref}` : `l${row.at}`)} - {#if row.status === 'removed'} -
      1. - - pass at {formatMicros(row.at)}s — removed -
      2. - {:else} -
      3. - - Lap {row.number} - {formatMicros(row.durationMicros)} -
      4. - {/if} - {/each} -
      - {/if} {/if} {/if} @@ -968,33 +1001,197 @@ {#each shownLaps.competitors as cl (cl.competitor.adapter + '/' + cl.competitor.competitor)} + {@const canCorrect = canControl && !resultLocked}
      -

      {competitorName(cl.competitor.competitor)}

      - {#if cl.laps.length === 0} -

      No laps yet.

      +

      + {competitorName(cl.competitor.competitor)} + {#if tuningPreview}re-detection preview{/if} +

      + {#if tuningPreview} + +
        + {#each previewRowList as row (row.status === 'removed' ? `x${row.ref}` : `${row.status}${row.at}`)} + {#if row.status === 'removed'} +
      1. + + pass at {formatMicros(row.at)}s — removed +
      2. + {:else if row.status === 'voided'} +
      3. + + crossing at {formatMicros(row.at)}s — voided by you, stays removed +
      4. + {:else} +
      5. + + Lap {row.number} + {formatMicros(row.durationMicros)} +
      6. + {/if} + {/each} +
      {:else} -
        - {#each cl.laps as lap (lap.end_ref)} -
      1. + {#if cl.laps.length === 0 && (cl.voided ?? []).length === 0} +

        No laps yet.

        + {:else} +
          + {#each cl.laps as lap (lap.end_ref)} +
        1. + + {#if canCorrect} + + {/if} +
        2. + {#if canCorrect && isSelected(cl.competitor.competitor, lap)} + +
        3. + + + + + +
        4. + {/if} + {/each} + {#each cl.voided ?? [] as v (v.pass_ref)} + +
        5. + + removed pass at {formatMicros(v.at)}s — stays removed +
        6. + {/each} +
        + {/if} + {#if canCorrect} +
        + {#if addLapOpen} + - Lap {lap.number} - {formatMicros(lap.duration_micros)} - -
      2. - {/each} -
      + + {:else} + + {/if} +
      + {/if} {/if}
      {/each} {:else}

      No lap list for this heat yet.

      + {#if canControl && !resultLocked && shownPilot !== undefined} + +
      + {#if addLapOpen} + + + + {:else} + + {/if} +
      + {/if} {/if} {#if canControl} @@ -1013,97 +1210,6 @@ > {:else} - -
      - - {#if selected} - Selected: {competitorName(selected.competitor)} · Lap {selected.lap.number} - {:else} - Select a lap to correct - {/if} - -
      - - - - - - - -
      -
      - - -
      - Add a lap -

      - {#if hasTrace} - Click a competitor's trace on the graph to add a lap at that time, or add one at a - typed time here. - {:else} - Add a missed lap at a typed time (source clock) — works even with no laps yet. - {/if} -

      -
      - - - -
      -
      -
      Competitor ruling @@ -1601,6 +1707,56 @@ /* The unified re-detection preview: one chronological list, big mono rows (sunlit-laptop readable). Kept rows read plain; added rows carry the accent "+" chip styling; removed rows read struck/dimmed danger — "this pass leaves the record on commit" at a glance. */ + .preview-badge { + margin-left: var(--gf-space-2); + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + color: var(--gf-warn); + } + .lap-row { + display: flex; + align-items: center; + gap: var(--gf-space-2); + } + .lap-row .lap { + flex: 1; + } + .lap-remove { + flex: none; + font-size: var(--gf-font-size-sm); + color: var(--gf-danger); + } + .lap-editor { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: var(--gf-space-2); + padding: var(--gf-space-2); + margin: 0 0 var(--gf-space-2); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + background: var(--gf-surface-sunken); + list-style: none; + } + .voided-row, + .preview-rows .voided { + display: flex; + align-items: center; + gap: var(--gf-space-2); + color: var(--gf-text-faint); + text-decoration: line-through; + list-style: none; + padding: var(--gf-space-1) 0; + } + .add-lap-row { + display: flex; + align-items: end; + gap: var(--gf-space-2); + margin-top: var(--gf-space-2); + } + .preview-rows { margin: var(--gf-space-2) 0 0; padding: 0; diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index c1f327a..96a581d 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -34,9 +34,8 @@ describe('Marshaling (Slice 3)', () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); render(Marshaling, { session }); - // Select ALICE's lap 2 (end_ref 14) and void it — the target must be 14, NOT a window offset. - await fireEvent.click(screen.getByRole('button', { name: /Lap 2\s*40\.500/ })); - await fireEvent.click(screen.getByRole('button', { name: 'Remove (void)' })); + // One click on the row's Remove: the target must be lap 2's end_ref 14, NOT a window offset. + await fireEvent.click(screen.getByRole('button', { name: 'Remove lap 2' })); expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 14 } }); }); @@ -137,7 +136,7 @@ describe('Marshaling (Slice 3)', () => { render(Marshaling, { session }); // Select ALICE's lap 2 (end_ref 14) and throw it out — keeps the lap but drops it from scoring. await fireEvent.click(screen.getByRole('button', { name: /Lap 2\s*40\.500/ })); - await fireEvent.click(screen.getByRole('button', { name: 'Throw out lap' })); + await fireEvent.click(screen.getByRole('button', { name: 'Throw out' })); expect(sendSpy).toHaveBeenCalledWith({ ThrowOutLap: { target: 14 } }); }); @@ -405,15 +404,14 @@ describe('Marshaling (Slice 3)', () => { expect(screen.getByRole('button', { name: /Lap 1\s*41\.000/ })).toBeInTheDocument(); expect(screen.getByText('CARMEN · DQ applied')).toBeInTheDocument(); - // …but every result-changing surface is gone: the lap action buttons… - expect(screen.queryByRole('button', { name: 'Remove (void)' })).toBeNull(); + // …but every result-changing surface is gone: the per-row Remove + the inline editor… + expect(screen.queryByRole('button', { name: /Remove lap/ })).toBeNull(); expect(screen.queryByRole('button', { name: 'Split' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Edit time' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Throw out lap' })).toBeNull(); - // …the Add-lap control… - expect(screen.queryByRole('button', { name: 'Add lap' })).toBeNull(); - expect(screen.queryByLabelText('Add-lap competitor')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Throw out' })).toBeNull(); + // …and the inline Add-lap control. + expect(screen.queryByRole('button', { name: '+ Add lap' })).toBeNull(); // …the penalty form + the reverse-ruling picker… expect(screen.queryByRole('button', { name: 'Apply' })).toBeNull(); expect(screen.queryByLabelText('Reverse ruling')).toBeNull(); @@ -457,10 +455,10 @@ describe('Marshaling (Slice 3)', () => { // correction surfaces back. pushLive({ ...liveRunning, phase: 'Unofficial', lifecycle: { Provisional: {} } }); await waitFor(() => - expect(screen.getByRole('button', { name: 'Remove (void)' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove lap 1' })).toBeInTheDocument() ); expect(screen.queryByRole('status', { name: 'Official result lock' })).toBeNull(); - expect(screen.getByRole('button', { name: 'Add lap' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '+ Add lap' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Void heat' })).toBeInTheDocument(); }); @@ -676,7 +674,8 @@ describe('Marshaling (Slice 3)', () => { const graph = screen.getByLabelText('RSSI signal graph'); // Click ALICE's lap-2 marker; the selection legend reflects exactly that lap. await fireEvent.click(within(graph).getByRole('button', { name: /Lap 2 at .* — select/ })); - expect(screen.getByText(/Selected: ALICE · Lap 2/)).toBeInTheDocument(); + // The selection opens the lap's INLINE editor in the lap list (the action surface). + expect(screen.getByLabelText('Edit lap 2')).toBeInTheDocument(); // The marker is now pressed (the two-way highlight). expect(within(graph).getByRole('button', { name: /Lap 2 at .* — select/ })).toHaveAttribute( 'aria-pressed', @@ -764,17 +763,86 @@ describe('Marshaling (Slice 3)', () => { }); }); + // ── Voided passes: the shared removal record (void ↔ re-detection) ────────────────────────── + describe('voided passes stay removed (the shared removal record)', () => { + // ALICE's lap list with lap 2's closing pass VOIDED by the RD: one surviving lap plus the + // removal record at 81.5s — the projection's `voided` field. + const voidedLapList: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } + ], + voided: [{ at: 81_500_000, pass_ref: 14 }] + } + ] + }; + + it('renders the RD-voided pass struck-through in the lap list', () => { + const { session } = makeTestSession({ live: liveRunning, laps: voidedLapList }); + render(Marshaling, { session }); + expect(screen.getByText(/removed pass at 1:21\.500s — stays removed/)).toBeInTheDocument(); + }); + + it('re-detection SUPPRESSES a crossing the RD voided — never re-proposed as an add', async () => { + // The trace still shows the 81.5s crossing (that's the whole bug): tuning must not + // offer it back. Touch the levels to enter preview mode, then assert the crossing shows + // as voided-stays-removed and the commit batch would not insert it. + const { session, sendSpy } = makeTestSession({ + live: liveRunning, + laps: voidedLapList, + signal: signalTrace + }); + render(Marshaling, { session }); + + // Nudge the ENTER level (explicit intent) to values that detect BOTH peaks. + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '111' } + }); + // The summary counts the suppressed crossing separately — never as an add. + const summary = await screen.findByTestId('redetect-summary'); + expect(summary.textContent).toContain('voided by you stay removed'); + expect(summary.textContent).toContain('+0 added'); + // The lap box (in preview mode — the re-detection also drops the phantom 0s opening + // pass) marks the suppressed crossing at its DETECTED instant, clearly non-addable. + expect( + screen.getByText(/crossing at 1:21\.000s — voided by you, stays removed/) + ).toBeInTheDocument(); + // Committing sends ONLY the legitimate removal — and, crucially, NO InsertLap for the + // suppressed crossing (the whole bug: the tuner used to re-add the voided lap). + await fireEvent.click(screen.getByRole('button', { name: 'Commit re-detection' })); + await waitFor(() => expect(sendSpy).toHaveBeenCalled()); + const inserts = sendSpy.mock.calls.filter( + ([c]) => typeof c === 'object' && c !== null && 'InsertLap' in c + ); + expect(inserts).toEqual([]); + }); + + it('the lap box only switches to preview on EXPLICIT tuning intent', () => { + // A trace whose recorded thresholds disagree with the official laps must not hijack the + // interactive lap list on its own (no drag/edit yet → the normal rows + actions render). + const { session } = makeTestSession({ + live: liveRunning, + laps: voidedLapList, + signal: signalTrace + }); + render(Marshaling, { session }); + expect(screen.queryByText(/re-detection preview/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Remove lap 1' })).toBeInTheDocument(); + }); + }); + // ── Add a brand-new lap (the explicit per-competitor control) ─────────────────────────────── describe('add a new lap (explicit control)', () => { it('adds a lap at a typed time via InsertLap', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); render(Marshaling, { session }); - await fireEvent.change(screen.getByLabelText('Add-lap competitor'), { - target: { value: 'ALICE' } - }); + // ALICE is the shown pilot; her lap box carries the inline Add control. + await fireEvent.click(screen.getByRole('button', { name: '+ Add lap' })); await fireEvent.input(screen.getByLabelText('Add-lap time'), { target: { value: '12.5' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Add lap' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); // The command carries the marshaled heat so the server routes the insertion into THAT // heat's scoring window even when a different heat is live. expect(sendSpy).toHaveBeenCalledWith({ @@ -798,13 +866,14 @@ describe('Marshaling (Slice 3)', () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: zeroLaps }); render(Marshaling, { session }); - // The empty competitor still renders + is selectable in the add-lap dropdown. - expect(screen.getByText('No laps yet.')).toBeInTheDocument(); - await fireEvent.change(screen.getByLabelText('Add-lap competitor'), { + // Show CARMEN (zero laps): her box renders empty WITH the inline Add control. + await fireEvent.change(screen.getByLabelText('Pilot to marshal'), { target: { value: 'CARMEN' } }); + expect(screen.getByText('No laps yet.')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '+ Add lap' })); await fireEvent.input(screen.getByLabelText('Add-lap time'), { target: { value: '8' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Add lap' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); expect(sendSpy).toHaveBeenCalledWith({ InsertLap: { adapter: 'rh-1', competitor: 'CARMEN', at: 8_000_000, heat: 'heat-1' } }); diff --git a/frontend/apps/rd-console/tests/redetect.test.ts b/frontend/apps/rd-console/tests/redetect.test.ts index cd71d31..65a1ef7 100644 --- a/frontend/apps/rd-console/tests/redetect.test.ts +++ b/frontend/apps/rd-console/tests/redetect.test.ts @@ -149,6 +149,52 @@ describe('previewLaps (consecutive-pass lap derivation)', () => { }); }); +describe('void suppression (the removal record and the tuner share data)', () => { + it('a detected crossing at an RD-voided time is SUPPRESSED, never added', () => { + // Blaze's repro: a real crossing that was not a full lap — the RD voided it, but the + // trace still shows it, so re-detection kept proposing it back as "a lap to add". + const current: { at: number; ref: number }[] = [ + { at: 1 * S, ref: 10 }, + { at: 81 * S, ref: 12 } + ]; + const detected = [1 * S, 41 * S, 81 * S]; // the trace still sees the voided crossing at 41s + const d = diffPasses(current, detected, DEFAULT_MATCH_TOLERANCE_MICROS, [41 * S + 100_000]); + expect(d.added).toEqual([]); // NOT re-proposed + expect(d.suppressed).toEqual([41 * S]); + expect(d.kept.map((k) => k.ref)).toEqual([10, 12]); + expect(d.removed).toEqual([]); + }); + + it('a genuinely new crossing away from any void is still added', () => { + const d = diffPasses( + [{ at: 1 * S, ref: 10 }], + [1 * S, 60 * S], + DEFAULT_MATCH_TOLERANCE_MICROS, + [41 * S] + ); + expect(d.added).toEqual([60 * S]); + expect(d.suppressed).toEqual([]); + }); + + it('previewRows carries the suppressed crossing as a voided row and drops it from the lap chain', () => { + const rows = previewRows( + [ + { at: 1 * S, ref: 10 }, + { at: 81 * S, ref: 12 } + ], + [1 * S, 41 * S, 81 * S], + DEFAULT_MATCH_TOLERANCE_MICROS, + [41 * S] + ); + // ONE kept lap spanning 1s -> 81s (the suppressed crossing is not in the chain), plus the + // voided marker row at its instant. + expect(rows).toEqual([ + { status: 'voided', at: 41 * S }, + { status: 'kept', number: 1, at: 81 * S, durationMicros: 80 * S } + ]); + }); +}); + describe('previewRows (the unified re-detection preview)', () => { // The canonical official chain: holeshot at 1s, gate passes at 41s + 81s (two 40s laps). const current = [ From b8d922f0afbdbed99da5408a9e11ddd433a297f5 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 17:26:04 +0000 Subject: [PATCH 348/362] fix(marshaling): run-scoped effectively-once rulings, restore path, tag-aware final lock (D25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-2 deep dive over the shared-removal-record work — two adversarial reviews plus a seeded marshaling combo fuzzer (docs/release-audit-2026-07.html §4): - Re-detection suppression runs AFTER diff matching: applied before, a voided instant could steal a surviving pass's nearest crossing and a commit would void the lap the RD kept; a re-added lap fought the stale record forever. - The removal record carries the RAW instant (the trace knows no re-times) and the standing void's offset; the projection's void-the-void resolution walks the chain with parity (depth-3 remove/restore/re-remove works); splits resolve their source recursively (splitting a split-synthetic used to ack ok and do nothing). - RESTORE is first-class: require_void_target admits a DetectionVoided target (void-the-void was unreachable through the command layer — a misclicked Remove was permanent) and the struck removal rows carry a Restore button. - heat_of_offset resolves bridge-stamped passes BY TAG, agreeing with the window fold — the positional resolution let rulings on late tagged passes bypass (or spuriously hit) the official-result lock. - Run-scoped + effectively-once command layer: abandoned-run targets rejected (stale screens told, not silently no-oped); protests and heat-voids require a current run (a pre-run filing was an invisible Finalize wedge); one standing throw-out per lap and DQ per pilot/heat (stacked duplicates made reversal a silent no-op); voiding a thrown-out lap's pass rejected (dangling ruling); insert/adjust/split instants validated — positive, bounded 24h, and never colliding into a fuzz-caught ZERO-DURATION lap. - Run-aware driver rechecks: each runtime clock captures a spawn watermark (the heat's latest transition offset) and stands down if ANYTHING moved — same-state rechecks couldn't tell a re-run from the run they were spawned for. - Console: tuning intent keyed per heat+pilot (a reused node seat carried stale levels + intent across heats); shared time inputs reset on selection/pilot moves; the preview renders once for a dual-adapter pilot; removal rows interleave chronologically; graph pan defers pointer capture until a real drag so marker clicks work while zoomed. - Fuzz: seeded 230-300-op command storms incl. hostile targets + a mid-race phase — no 5xx, no wedge, every projection invariant held, zero laps lost on the live heat under marshaling fire. Co-Authored-By: Claude Fable 5 --- bindings/VoidedPass.ts | 11 +- crates/app/src/source.rs | 54 ++- crates/projection/src/lib.rs | 177 ++++++- crates/server/src/control_handler.rs | 454 +++++++++++++++++- docs/decisions.html | 27 ++ docs/marshaling.html | 11 + docs/release-audit-2026-07.html | 89 +++- .../apps/rd-console/src/lib/RssiGraph.svelte | 21 +- frontend/apps/rd-console/src/lib/redetect.ts | 37 +- .../rd-console/src/screens/Marshaling.svelte | 95 +++- .../rd-console/tests/MarshalingScreen.test.ts | 11 +- .../apps/rd-console/tests/redetect.test.ts | 32 ++ 12 files changed, 937 insertions(+), 82 deletions(-) diff --git a/bindings/VoidedPass.ts b/bindings/VoidedPass.ts index 2997b90..622351b 100644 --- a/bindings/VoidedPass.ts +++ b/bindings/VoidedPass.ts @@ -7,11 +7,16 @@ import type { SourceTime } from "./SourceTime"; */ export type VoidedPass = { /** - * Source-clock time (µs) the voided pass was recorded at (re-time applied, like a - * surviving pass — the instant re-detection would re-find it at). + * 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, }; +pass_ref: LogRef, +/** + * The **standing void event's** offset — the target a RESTORE (void-the-void) addresses. + */ +void_ref: LogRef, }; diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 40cb59e..217d635 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -1564,6 +1564,13 @@ fn spawn_start_driver( 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. @@ -1587,6 +1594,9 @@ fn spawn_start_driver( 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( @@ -1646,6 +1656,15 @@ fn spawn_completion_driver( _ => 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). @@ -1667,7 +1686,7 @@ fn spawn_completion_driver( // 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) { + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { eprintln!( "gridfpv: completion driver could not append time-limit Finished: {e:?}" ); @@ -1690,7 +1709,7 @@ fn spawn_completion_driver( 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) { + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { eprintln!( "gridfpv: completion driver could not append timed-window Finished: {e:?}" ); @@ -1705,7 +1724,7 @@ fn spawn_completion_driver( // 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) { + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { eprintln!("gridfpv: completion driver could not append Finished: {e:?}"); } return; @@ -1714,6 +1733,21 @@ fn spawn_completion_driver( }) } +/// 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 @@ -1721,6 +1755,7 @@ fn spawn_completion_driver( fn append_finished_if_running( state: &AppState, heat: &HeatId, + spawn_watermark: Option, ) -> Result<(), gridfpv_server::error::ProtocolError> { let h = heat.clone(); state @@ -1733,6 +1768,9 @@ fn append_finished_if_running( 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(|_| ()) @@ -1785,6 +1823,13 @@ fn spawn_auto_official_driver( ) -> 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. @@ -1848,6 +1893,9 @@ fn spawn_auto_official_driver( 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( diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 15e79a8..3384cc2 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -121,11 +121,14 @@ pub struct CompetitorLaps { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub struct VoidedPass { - /// Source-clock time (µs) the voided pass was recorded at (re-time applied, like a - /// surviving pass — the instant re-detection would re-find it at). + /// 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. + pub void_ref: LogRef, } impl CompetitorLaps { @@ -312,10 +315,13 @@ where corrected_and_voided_passes(events).0 } +/// One RD-voided pass as the fold emits it: `(pass offset, standing void event's offset, pass)`. +pub type VoidedEmit = (u64, u64, Pass); + /// [`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<(u64, Pass)>) +pub fn corrected_and_voided_passes<'a, I>(events: I) -> (Vec<(u64, Pass)>, Vec) where I: IntoIterator, { @@ -405,7 +411,10 @@ 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 entry in entries.values() { + // 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 { // 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 @@ -415,12 +424,16 @@ where // 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, @@ -431,15 +444,27 @@ 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); } } } @@ -451,22 +476,32 @@ where // 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<(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() { let is_voided = voided.get(offset).copied().unwrap_or(false); - let sink: &mut Vec<(u64, Pass)> = if is_voided { &mut voided_out } else { &mut out }; + 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(); - if let Some(at) = retime.get(offset) { - p.at = *at; + // 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 let Some(at) = retime.get(offset) { - p.at = *at; + if !is_voided { + if let Some(at) = retime.get(offset) { + p.at = *at; + } } sink.push((*offset, p)); } @@ -476,10 +511,18 @@ where // 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. - let src = match entries.get(target) { - Some(Entry::RawPass(p)) => Some((*p).clone()), - Some(Entry::Inserted(p)) => Some(p.clone()), - _ => None, + // 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(( @@ -498,6 +541,12 @@ where } Entry::Adjusted { .. } | Entry::Voided { .. } => {} } + 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)); + } + } } (out, voided_out) } @@ -532,7 +581,7 @@ where // 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, pass) in voided { + for (offset, void_offset, pass) in voided { by_competitor .entry(CompetitorKey::from_pass(&pass)) .or_default(); @@ -542,6 +591,7 @@ where .push(VoidedPass { at: pass.at, pass_ref: LogRef(offset), + void_ref: LogRef(void_offset), }); } @@ -1497,6 +1547,7 @@ mod marshaling_tests { vec![VoidedPass { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), + void_ref: LogRef(3), }] ); @@ -1513,6 +1564,90 @@ mod marshaling_tests { assert!(cl.voided.is_empty()); } + #[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), + }] + ); + } + + #[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), + }] + ); + } + + #[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" + ); + } + #[test] fn voiding_an_insert_removes_the_synthetic_pass() { // A void may target a `LapInserted` (an adjudication) — voiding offset 2 diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 7d1f2d4..535efa4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -993,13 +993,32 @@ fn command_to_event(state: &AppState, command: Command) -> Result { - require_pass_target(state, 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 } => { @@ -1007,6 +1026,17 @@ fn command_to_event(state: &AppState, command: Command) -> Result Result { 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()?; @@ -1046,8 +1078,12 @@ fn command_to_event(state: &AppState, command: Command) -> Result { require_scheduled_heat(state, &heat)?; require_not_final(state, &heat)?; - // One EFFECTIVE void per heat: a stacked second void made the first reversal a - // silent no-op (the heat stayed voided behind an ok-acked ReverseRuling). + // 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 }) } @@ -1069,6 +1105,12 @@ fn command_to_event(state: &AppState, command: Command) -> Result Result { 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 @@ -1107,6 +1154,11 @@ fn command_to_event(state: &AppState, command: Command) -> Result { 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, @@ -1281,6 +1333,11 @@ pub(crate) fn heat_of_offset(events: &[Event], target: LogRef) -> Option | 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, .. } @@ -1348,6 +1405,200 @@ fn require_lap_end_target(state: &AppState, target: LogRef) -> Result<(), Protoc /// 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) { @@ -1494,8 +1745,10 @@ fn require_heat_not_voided(state: &AppState, heat: &HeatId) -> Result<(), Protoc _ => 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 { @@ -2361,6 +2614,16 @@ mod tests { 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, @@ -2378,11 +2641,11 @@ mod tests { if *competitor == CompetitorRef("A".into()) && note == "cut the course" ))); - // Resolve the protest at offset 1. + // Resolve the protest (offset 2 — after the schedule + the run). let ack = apply_command( &state, Command::ResolveProtest { - target: LogRef(1), + target: LogRef(2), outcome: ProtestOutcome::Upheld, }, ); @@ -2391,7 +2654,7 @@ mod tests { assert!(events.iter().any(|e| matches!( e, Event::ProtestResolved { target, outcome: ProtestOutcome::Upheld } - if *target == LogRef(1) + if *target == LogRef(2) ))); // Resolving a non-protest (the HeatScheduled at offset 0) is rejected, nothing appended. @@ -2508,6 +2771,159 @@ mod tests { 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 @@ -2549,9 +2965,8 @@ mod tests { #[test] fn reverse_ruling_accepts_any_ruling_target() { let mut log = InMemoryLog::default(); - // offset 0: a pass (so a throw-out has a valid target) - EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap(); - // offset 1: the heat + // 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 { @@ -2565,19 +2980,28 @@ mod tests { 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 2), a heat-void (offset 3) — both reversible rulings. - assert!(apply_command(&state, Command::ThrowOutLap { target: LogRef(0) }).ok); + // 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); - // Reversing the throw-out (offset 2) and the heat-void (offset 3) both succeed. - for target in [LogRef(2), LogRef(3)] { + 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 0 (not a ruling) is rejected. - let ack = apply_command(&state, Command::ReverseRuling { target: LogRef(0) }); + // 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); } diff --git a/docs/decisions.html b/docs/decisions.html index 4aea33c..7310476 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -730,6 +730,33 @@

      D24 · A raced round's scoring config is frozen (the heats-per-pilo 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.
      +
      +

      Internal design documentation · ← back to docs index diff --git a/docs/marshaling.html b/docs/marshaling.html index d3e6c6c..6651421 100644 --- a/docs/marshaling.html +++ b/docs/marshaling.html @@ -247,6 +247,17 @@

      3.3 Differentiator — the governance layer nobody has

      href="decisions.html#d23">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. +

      4. Architectural fit

      diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html index 8520d1d..de2da9d 100644 --- a/docs/release-audit-2026-07.html +++ b/docs/release-audit-2026-07.html @@ -125,7 +125,94 @@

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

      shape, the SignalTrace projection, and the null-competitor audit row.
    -

    4. Deferred — recorded, deliberately not built

    +

    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

    • 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 diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index cec3f39..bfec842 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -400,14 +400,16 @@ } // Drag-to-pan while zoomed. Starts on the svg background (the threshold handles stop - // propagation so their drags stay theirs); only a real horizontal move pans, so marker - // clicks still land. - let panning = $state<{ ref: CompetitorRef; lastX: number } | null>(null); + // propagation so their drags stay theirs). Pointer capture is DEFERRED until the pointer + // actually moves a few units: capturing on pointerdown retargets the browser's + // compatibility `click` to the svg, which silently broke lap-MARKER clicks whenever + // zoomed — precisely when marshals click markers. + const PAN_START_UNITS = 4; + let panning = $state<{ ref: CompetitorRef; lastX: number; engaged: boolean } | null>(null); function startPan(e: PointerEvent, ref: CompetitorRef): void { if (!zoom || zoom.ref !== ref) return; - panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement) }; - (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement), engaged: false }; } function movePan(e: PointerEvent, full: { from: number; to: number }): void { @@ -415,6 +417,13 @@ const svg = e.currentTarget as SVGSVGElement; const x = pointerX(e, svg); const dx = x - panning.lastX; + if (!panning.engaged) { + if (Math.abs(dx) < PAN_START_UNITS) return; // a click in progress, not a pan + // A real drag: NOW capture (safe — any click this gesture could produce is a drag end). + svg.setPointerCapture?.(e.pointerId); + panning = { ...panning, engaged: true, lastX: x }; + return; + } if (dx === 0) return; panning = { ...panning, lastX: x }; const view = viewSpanOf(panning.ref, full); @@ -573,7 +582,7 @@ plot, so the SVG stays a non-interactive `role="img"` figure. --> Math.abs(v - t) <= toleranceMicros)) suppressed.push(t); - else live.push(t); - } - - // Every candidate pairing within tolerance, nearest first — the greedy order. + // Match FIRST, suppress SECOND. Suppression must only claim crossings that would otherwise + // become ADDS: running it before the match let a voided instant steal an official pass's + // nearest crossing (a double-detection the RD half-removed), flipping the SURVIVING lap + // into `removed` — a commit would then void the lap the RD kept. Matching first also means + // a lap the RD re-adds at a once-voided instant pairs with its crossing (kept) instead of + // fighting the stale removal record forever. const candidates: { ci: number; di: number; dist: number }[] = []; for (let ci = 0; ci < current.length; ci++) { - for (let di = 0; di < live.length; di++) { - const dist = Math.abs(live[di] - current[ci].at); + for (let di = 0; di < detected.length; di++) { + const dist = Math.abs(detected[di] - current[ci].at); if (dist <= toleranceMicros) candidates.push({ ci, di, dist }); } } @@ -146,13 +141,25 @@ export function diffPasses( if (matchedCurrent.has(ci) || matchedDetected.has(di)) continue; matchedCurrent.add(ci); matchedDetected.add(di); - kept.push({ at: current[ci].at, ref: current[ci].ref, detectedAt: live[di] }); + kept.push({ at: current[ci].at, ref: current[ci].ref, detectedAt: detected[di] }); } kept.sort((a, b) => a.at - b.at); + // THEN: of the unmatched crossings, suppress those the RD explicitly voided (within + // tolerance). The trace still shows the crossing — the void must win here, or the tuner + // keeps offering a removed lap back as an add. + const added: number[] = []; + const suppressed: number[] = []; + for (let di = 0; di < detected.length; di++) { + if (matchedDetected.has(di)) continue; + const t = detected[di]; + if (voidedAt.some((v) => Math.abs(v - t) <= toleranceMicros)) suppressed.push(t); + else added.push(t); + } + return { kept, - added: live.filter((_, di) => !matchedDetected.has(di)), + added, removed: current.filter((_, ci) => !matchedCurrent.has(ci)), suppressed }; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 2f42534..7551c96 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -280,6 +280,9 @@ if (selected && selected.competitor === competitor && selected.lap.end_ref === lap.end_ref) { selected = null; // toggle off } else { + // A fresh selection gets a fresh time input: a value typed for lap 2 must not sit + // pre-armed in lap 5's editor (one misread click would re-time it). + editSeconds = 0; selected = { competitor, lap }; } } @@ -407,6 +410,16 @@ addLapOpen = false; } + /** RESTORE a removed pass: void-the-void, targeting the standing removal event. The + * sanctioned undo for a mistaken one-click Remove (the fold walks the chain; the pass + * returns to the laps and leaves the removal record, re-opening re-detection there). */ + function doRestorePass(v: { void_ref: number }): Promise { + return submitCorrection(async () => { + const ack = await session.send(voidDetectionCommand(v.void_ref)); + if (ack.ok) await afterCorrection(); + }); + } + /** Remove (void) a lap straight from its row — the one-click removal on the lap list. */ function doVoidLap(competitor: CompetitorRef, lap: Lap): Promise { return submitCorrection(async () => { @@ -603,6 +616,13 @@ // below scope to just them. `shownPilot` is the chosen pilot if still in the field, else the first // competitor — so it self-heals when the heat changes without an extra effect. let selectedPilot = $state(undefined); + // Pilot/heat moves close the add-lap row and clear its time (state is shared across boxes). + $effect(() => { + void shownPilot; + void heat; + addLapOpen = false; + addLapSeconds = 0; + }); const shownPilot = $derived( selectedPilot !== undefined && competitors.includes(selectedPilot) ? selectedPilot @@ -637,7 +657,7 @@ const tuneTrace = $derived( signalTrace?.competitors.find((c) => c.competitor.competitor === shownPilot) ); - let tuneFor = $state(undefined); + let tuneFor = $state(undefined); let tuneEnter = $state(0); let tuneExit = $state(0); // Whether the RD has ACTIVELY moved the levels this session (a drag or a typed edit). The @@ -663,8 +683,12 @@ tuneFor = undefined; return; } - if (tuneFor === t.competitor.competitor) return; - tuneFor = t.competitor.competitor; + // Keyed by HEAT + competitor: node-seat refs (`node-0`) recur across heats, and a + // ref-only key carried the previous heat's levels AND tuning intent into the next one + // (instant phantom preview with Commit armed). + const key = `${heat ?? ''}::${t.competitor.competitor}`; + if (tuneFor === key) return; + tuneFor = key; tuneTouched = false; const rec = recordedThresholds(t); tuneEnter = rec.enter; @@ -685,10 +709,11 @@ // Flatten across entries: the shown pilot can hold several lap-list entries (one per // adapter after a mid-heat failover) — the tune diff must see ALL their official passes. const shownPilotLaps = $derived((shownLaps?.competitors ?? []).flatMap((c) => c.laps)); + const tuneKeyCurrent = $derived( + tuneTrace !== undefined && tuneFor === `${heat ?? ''}::${tuneTrace.competitor.competitor}` + ); const detectedPassTimes = $derived( - tuneTrace && tuneValid && tuneFor === shownPilot - ? detectPasses(tuneTrace, tuneEnter, tuneExit) - : [] + tuneTrace && tuneValid && tuneKeyCurrent ? detectPasses(tuneTrace, tuneEnter, tuneExit) : [] ); // The RD's removal record (`CompetitorLaps.voided`) — the lap list and the tuner share this // data, so a crossing the marshal explicitly voided is SUPPRESSED from re-detection (the @@ -722,12 +747,7 @@ /** Whether the lap box is in live-preview mode: the RD actively moved the levels AND the * re-detection differs from the official record. Never entered passively. */ const tuningPreview = $derived( - canControl && - tuneTouched && - tuneTrace != null && - tuneFor === shownPilot && - tuneValid && - redetectDirty + canControl && tuneTouched && tuneTrace != null && tuneKeyCurrent && tuneValid && redetectDirty ); function doResetThresholds(): void { @@ -901,7 +921,7 @@ {/if} {#if hasShownTrace && shownTrace} - {@const tuning = canControl && tuneTrace != null && tuneFor === shownPilot} + {@const tuning = canControl && tuneTrace != null && tuneKeyCurrent} - {#each shownLaps.competitors as cl (cl.competitor.adapter + '/' + cl.competitor.competitor)} + {#each shownLaps.competitors as cl, entryIndex (cl.competitor.adapter + '/' + cl.competitor.competitor)} {@const canCorrect = canControl && !resultLocked}

      {competitorName(cl.competitor.competitor)} - {#if tuningPreview}re-detection preview{/if} + {#if tuningPreview && entryIndex === 0}re-detection preview{/if}

      - {#if tuningPreview} + {#if tuningPreview && entryIndex > 0} + + {:else if tuningPreview}
    • @@ -1121,6 +1166,17 @@ removed pass at {formatMicros(v.at)}s — stays removed + {#if canCorrect} + + {/if}
    • {/each} @@ -1750,6 +1806,11 @@ list-style: none; padding: var(--gf-space-1) 0; } + .lap-restore { + flex: none; + font-size: var(--gf-font-size-sm); + text-decoration: none; + } .add-lap-row { display: flex; align-items: end; diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 96a581d..26646d4 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -774,7 +774,7 @@ describe('Marshaling (Slice 3)', () => { laps: [ { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } ], - voided: [{ at: 81_500_000, pass_ref: 14 }] + voided: [{ at: 81_500_000, pass_ref: 14, void_ref: 21 }] } ] }; @@ -819,6 +819,15 @@ describe('Marshaling (Slice 3)', () => { expect(inserts).toEqual([]); }); + it('Restore on a removed pass sends void-the-void at the STANDING removal event', async () => { + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: voidedLapList }); + render(Marshaling, { session }); + await fireEvent.click( + screen.getByRole('button', { name: /Restore removed pass at 1:21\.500s/ }) + ); + expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 21 } }); + }); + it('the lap box only switches to preview on EXPLICIT tuning intent', () => { // A trace whose recorded thresholds disagree with the official laps must not hijack the // interactive lap list on its own (no drag/edit yet → the normal rows + actions render). diff --git a/frontend/apps/rd-console/tests/redetect.test.ts b/frontend/apps/rd-console/tests/redetect.test.ts index 65a1ef7..6fbc961 100644 --- a/frontend/apps/rd-console/tests/redetect.test.ts +++ b/frontend/apps/rd-console/tests/redetect.test.ts @@ -176,6 +176,38 @@ describe('void suppression (the removal record and the tuner share data)', () => expect(d.suppressed).toEqual([]); }); + it('a voided instant NEAR a surviving pass cannot steal its match (match first, suppress second)', () => { + // The double-detection case: the RD kept the 10.0s pass and voided the 10.3s duplicate. + // The tuned levels see one crossing at 10.2s. Suppress-first would claim it for the void + // and mark the KEPT pass removed — a commit would void the lap the RD kept. + const d = diffPasses([{ at: 10 * S, ref: 7 }], [10.2 * S], DEFAULT_MATCH_TOLERANCE_MICROS, [ + 10.3 * S + ]); + expect(d.kept.map((k) => k.ref)).toEqual([7]); + expect(d.removed).toEqual([]); + expect(d.suppressed).toEqual([]); + expect(d.added).toEqual([]); + }); + + it('a lap RE-ADDED at a once-voided instant matches its crossing instead of fighting the record', () => { + // RD removed the 41s lap by mistake, added it back: the official (inserted) pass at 41s + // must pair with the 41s crossing (kept) — the stale removal record must not force an + // eternal remove-and-re-add loop. + const d = diffPasses( + [ + { at: 1 * S, ref: 10 }, + { at: 41 * S, ref: 30 } // the re-added pass + ], + [1 * S, 41 * S], + DEFAULT_MATCH_TOLERANCE_MICROS, + [41 * S] // the old void, still on record + ); + expect(d.kept.map((k) => k.ref)).toEqual([10, 30]); + expect(d.removed).toEqual([]); + expect(d.added).toEqual([]); + expect(d.suppressed).toEqual([]); + }); + it('previewRows carries the suppressed crossing as a voided row and drops it from the lap chain', () => { const rows = previewRows( [ From f1f32cf792617bdfa4683e2962edd8360825dd19 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 17:31:28 +0000 Subject: [PATCH 349/362] test(contract): pin run-scoped heat-voids (D25) Co-Authored-By: Claude Fable 5 --- frontend/contract/control.contract.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/contract/control.contract.ts b/frontend/contract/control.contract.ts index 2c54bb4..4eb4f1c 100644 --- a/frontend/contract/control.contract.ts +++ b/frontend/contract/control.contract.ts @@ -98,7 +98,7 @@ describe('seam 5: control command shape + headers', () => { expect(illegal.error?.code).toBe('BadRequest'); }); - it('Register + the marshaling adjudications ack ok', async () => { + it('Register + the marshaling adjudications ack ok (heat-voids are run-scoped)', async () => { await rdControl(director.baseUrl, TOKEN, { ScheduleHeat: { heat: 'h-marshal', lineup: ['A'] } }); @@ -114,6 +114,20 @@ describe('seam 5: control command shape + headers', () => { } }); expect(penalty.ok).toBe(true); + // A heat-void is RUN-SCOPED (D25): voiding a heat that never ran is rejected — there is + // nothing to void, and the inert ruling would block a real void later. + const preRun = await rdControl(director.baseUrl, TOKEN, { VoidHeat: { heat: 'h-marshal' } }); + expect(preRun.ok).toBe(false); + expect(preRun.error?.code).toBe('BadRequest'); + // Drive the heat into a run; the void is then legal. + for (const cmd of [ + { Stage: { heat: 'h-marshal' } }, + { Start: { heat: 'h-marshal' } }, + { SkipCountdown: { heat: 'h-marshal' } } + ]) { + const ack = await rdControl(director.baseUrl, TOKEN, cmd); + expect(ack.ok).toBe(true); + } const voidHeat = await rdControl(director.baseUrl, TOKEN, { VoidHeat: { heat: 'h-marshal' } }); expect(voidHeat.ok).toBe(true); }); From 9c0e5103ac071fa0c9035c2aff2a85d39629d689 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 19:22:10 +0000 Subject: [PATCH 350/362] fix(polish): final-freeze pass gate, skew-safe rendering, tone round-trip, orphan pruning + zoom tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-polish batch over the deferred audit items: - FINAL FREEZE (audit round-2 §4 deferral, the top remaining integrity item): a pass landing at/after a heat's Finalized transition never joins its window (heat, class, and live folds) — a delayed RotorHazard catch-up pass, tagged or untagged, could silently change an official result with no command and no audit entry. Rulings are unaffected: Revert re-opens marshaling, not the observation record. - Version-skew rendering: an unmodeled timer kind now tags/labels/tones as unknown with an update nudge (it mislabeled as RotorHazard and field access CRASHED the Timers page); an unmodeled result metric labels 'unsupported' instead of a DNF-reading em dash. - Start-procedure round-trip: the rounds form models only the min/max delay but the server replaces the round wholesale — editing any round ERASED a stored start tone (and would flatten a future non-randomized mode). The stored procedure now round-trips verbatim under the form's fields, the same preserved-seeding pattern that saved bracket chains. - Housekeeping: bridge + presence tasks EXIT when their event is deleted (the log Arc kept orphans polling forever) and the spawners prune their attached sets; the presence reconciler dedups CompetitorSeen within a poll batch. - Test debt: the RSSI zoom/pan surface gets its suite — wheel/buttons/Fit, and the deferred-capture regression (a plain marker click selects while zoomed; a real drag captures and pans). Pan/zoom coordinates are NaN-guarded against coordinate-less synthetic pointer events. Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 24 +++++ crates/server/src/app.rs | 78 +++++++++++++++-- crates/server/src/live_state.rs | 31 ++++++- .../apps/rd-console/src/lib/RssiGraph.svelte | 5 +- frontend/apps/rd-console/src/lib/timers.ts | 25 ++++-- .../rd-console/src/screens/EventRounds.svelte | 25 +++++- .../apps/rd-console/tests/EventRounds.test.ts | 39 +++++++++ .../apps/rd-console/tests/RssiGraph.test.ts | 87 +++++++++++++++++++ frontend/apps/rd-console/tests/timers.test.ts | 19 +++- frontend/packages/components/src/format.ts | 5 +- .../packages/components/tests/format.test.ts | 5 ++ 11 files changed, 322 insertions(+), 21 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 217d635..c27eec7 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -511,6 +511,11 @@ pub fn spawn_registry_bridge( 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; @@ -601,6 +606,12 @@ pub(crate) async fn run_bridge( 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 @@ -699,6 +710,8 @@ pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> { 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; @@ -734,6 +747,10 @@ pub(crate) async fn run_presence_reconciler( 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. @@ -748,6 +765,10 @@ pub(crate) async fn run_presence_reconciler( 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 { @@ -755,6 +776,9 @@ pub(crate) async fn run_presence_reconciler( competitor, } = event { + if !seen_this_batch.insert((adapter.clone(), competitor.clone())) { + continue; + } reconcile_seen( &state, ®istry, &pilots, &event_id, &bindings, adapter, competitor, ); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8f25454..fee80cc 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1710,9 +1710,14 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 } } // 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`). + // 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)) { + 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())); } } @@ -1921,6 +1926,7 @@ fn now_micros() -> i64 { /// 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). @@ -1952,11 +1958,17 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, } // 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. - Event::Pass(p) => match &p.heat { - Some(h) => h == heat && offset >= run_start, - None => active && offset >= run_start, - }, + // 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, }; @@ -2096,6 +2108,58 @@ mod tests { ] } + /// 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). diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index c1759f2..a4263c7 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -265,6 +265,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // 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( window .iter() @@ -276,9 +277,12 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // 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. + // (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) => p.heat.as_ref().is_none_or(|h| h == ¤t_heat), + Event::Pass(p) => { + *i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == ¤t_heat) + } _ => true, } }) @@ -604,6 +608,29 @@ pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { 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(); diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index bfec842..7bdce66 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -409,7 +409,9 @@ function startPan(e: PointerEvent, ref: CompetitorRef): void { if (!zoom || zoom.ref !== ref) return; - panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement), engaged: false }; + const x = pointerX(e, e.currentTarget as SVGSVGElement); + if (!Number.isFinite(x)) return; // ditto — never seed a drag from a coordinate-less event + panning = { ref, lastX: x, engaged: false }; } function movePan(e: PointerEvent, full: { from: number; to: number }): void { @@ -417,6 +419,7 @@ const svg = e.currentTarget as SVGSVGElement; const x = pointerX(e, svg); const dx = x - panning.lastX; + if (!Number.isFinite(dx)) return; // a coordinate-less synthetic event must not corrupt the view if (!panning.engaged) { if (Math.abs(dx) < PAN_START_UNITS) return; // a click in progress, not a pan // A real drag: NOW capture (safe — any click this gesture could produce is a drag end). diff --git a/frontend/apps/rd-console/src/lib/timers.ts b/frontend/apps/rd-console/src/lib/timers.ts index 2b27e25..eeb9287 100644 --- a/frontend/apps/rd-console/src/lib/timers.ts +++ b/frontend/apps/rd-console/src/lib/timers.ts @@ -8,8 +8,11 @@ */ import type { Timer, TimerKind } from '@gridfpv/types'; -/** The two selectable kinds in the add/edit dialog (the discriminant tag). */ -export type TimerKindTag = 'Mock' | 'Rotorhazard'; +/** The two selectable kinds in the add/edit dialog (the discriminant tag). `'Unknown'` is the + * version-skew fallback: a NEWER Director may send a kind this console build doesn't model + * yet, and it must render labeled (not mislabeled as RotorHazard, and never crash on a field + * access) — the timer is still real and still selectable. */ +export type TimerKindTag = 'Mock' | 'Rotorhazard' | 'Unknown'; /** Sensible defaults for a fresh **Mock** timer: a handful of laps at a one-minute-ish pace. */ export const DEFAULT_MOCK_LAPS = 3; @@ -17,17 +20,24 @@ export const DEFAULT_MOCK_LAP_MS = 30_000; /** The discriminant tag of a kind (`'Mock'` | `'Rotorhazard'`). */ export function kindTag(kind: TimerKind): TimerKindTag { - return 'Mock' in kind ? 'Mock' : 'Rotorhazard'; + if ('Mock' in kind) return 'Mock'; + if ('Rotorhazard' in kind) return 'Rotorhazard'; + return 'Unknown'; } /** The short display label for the kind **badge** (RotorHazard is the brand spelling). */ export function kindLabel(kind: TimerKind): string { - return 'Mock' in kind ? 'Mock' : 'RotorHazard'; + if ('Mock' in kind) return 'Mock'; + if ('Rotorhazard' in kind) return 'RotorHazard'; + // A newer Director's kind: show its discriminant verbatim rather than a wrong brand. + return Object.keys(kind)[0] ?? 'Unknown'; } /** The Badge `tone` for a kind: Mock is the brand accent; RotorHazard reads as informational. */ -export function kindTone(kind: TimerKind): 'accent' | 'info' { - return 'Mock' in kind ? 'accent' : 'info'; +export function kindTone(kind: TimerKind): 'accent' | 'info' | 'neutral' { + if ('Mock' in kind) return 'accent'; + if ('Rotorhazard' in kind) return 'info'; + return 'neutral'; } /** A one-line summary of a kind's config for the timer row (the sim pace, or the RH url). */ @@ -37,7 +47,8 @@ export function kindSummary(kind: TimerKind): string { const lapName = laps === 1 ? 'lap' : 'laps'; return `${laps} ${lapName} · ${(lap_ms / 1000).toFixed(1)}s pace`; } - return kind.Rotorhazard.url || 'No URL set'; + if ('Rotorhazard' in kind) return kind.Rotorhazard.url || 'No URL set'; + return 'Unsupported by this console build — update the console'; } /** Whether a timer is the undeletable built-in Mock (its reserved id). */ diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 1b57920..2e30efe 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -523,6 +523,12 @@ // bracket level's "winners of Semifinal" seeding to FromRoster — a grace-window tweak destroyed // the bracket chain. When set, the seeding controls lock and submit round-trips it unchanged. let editPreservedSeeding = $state(undefined); + // The edited round's stored start procedure, verbatim — same wholesale-replace trap as the + // seeding above: the form models only min/max delay, so rebuilding the procedure from the + // two inputs silently ERASED a configured start `tone` (and would flatten any future + // non-randomized mode back to randomized-delay). Submit spreads this under the form's + // fields, so everything the form doesn't model survives the round trip. + let editPreservedStart = $state(undefined); // The chosen format's params, as a `key → value` map (Rounds form redesign item 4): every param // the format declares is shown inline as a proper labeled field, seeded from its schema default // (or the edited round's stored value). On a format switch the map is re-seeded to the new @@ -698,6 +704,7 @@ seedSources = new Set(); seedTopN = 8; editPreservedSeeding = undefined; + editPreservedStart = undefined; selectedNodes = new Set(); paramValues = {}; pointsTable = [...DEFAULT_POINTS_TABLE]; @@ -783,6 +790,7 @@ const stagingTotal = round.staging_timer_secs ?? 300; stagingMinutes = Math.floor(stagingTotal / 60); stagingSeconds = stagingTotal % 60; + editPreservedStart = round.start_procedure ?? undefined; startMinSeconds = msToSeconds(round.start_procedure?.min_delay_ms ?? 2000); startMaxSeconds = msToSeconds(round.start_procedure?.max_delay_ms ?? 5000); const grace = round.grace_window; @@ -926,9 +934,24 @@ * applies). */ function buildStartProcedure(): StartProcedure { + // A mode this form can't model (a future fixed-countdown / external trigger): round-trip + // it VERBATIM — the delay inputs simply don't apply to it. + if ( + editPreservedStart && + (editPreservedStart as { mode: string }).mode !== 'randomized-delay' + ) { + return editPreservedStart; + } const min = secondsToMs(startMinSeconds); const max = Math.max(min, secondsToMs(startMaxSeconds)); - return { mode: 'randomized-delay', min_delay_ms: min, max_delay_ms: max }; + // Spread the stored procedure UNDER the form's fields: the `tone` (and any additive future + // field) survives; only what the form actually edits is rewritten. + return { + ...editPreservedStart, + mode: 'randomized-delay', + min_delay_ms: min, + max_delay_ms: max + }; } /** The completion grace window as a bounded `Duration` (seconds → micros). */ diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index f4f9852..7428846 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -451,6 +451,45 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(req.grace_window).toEqual({ Duration: { micros: 5_000_000 } }); }); + it('a configured start TONE survives an edit the form never touched (verbatim round-trip)', async () => { + // The form models only the min/max delay; the server replaces the round WHOLESALE on + // update — so rebuilding start_procedure from the two inputs silently ERASED a stored + // tone. The stored procedure must round-trip under the form's fields. + const impls = baseImpls(); + const toned: RoundDef = { + ...QUAL, + start_procedure: { + mode: 'randomized-delay', + min_delay_ms: 2000, + max_delay_ms: 5000, + tone: { pattern: 'triple-beep', volume: 0.8 } + } as RoundDef['start_procedure'] + }; + const updateRoundImpl = vi.fn(async (_b, _e, _id, req) => ({ ...toned, ...req })); + const { session } = makeTestSession({ + ...impls, + updateRoundImpl, + event: { ...EVENT, rounds: [toned] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + // Touch something unrelated (a grace tweak — the exact trap that destroyed seedings). + await fireEvent.input(screen.getByLabelText('Grace window seconds'), { + target: { value: '10' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Save round' })); + + await waitFor(() => expect(updateRoundImpl).toHaveBeenCalledTimes(1)); + const [, , , req] = updateRoundImpl.mock.calls[0]; + expect(req.start_procedure).toEqual({ + mode: 'randomized-delay', + min_delay_ms: 2000, + max_delay_ms: 5000, + tone: { pattern: 'triple-beep', volume: 0.8 } + }); + }); + it('surfaces the chosen format’s params inline as labeled fields, seeded from their defaults', async () => { const impls = baseImpls(); const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); diff --git a/frontend/apps/rd-console/tests/RssiGraph.test.ts b/frontend/apps/rd-console/tests/RssiGraph.test.ts index 5a8c81c..49cbe5c 100644 --- a/frontend/apps/rd-console/tests/RssiGraph.test.ts +++ b/frontend/apps/rd-console/tests/RssiGraph.test.ts @@ -438,3 +438,90 @@ describe('RssiGraph threshold tuning + preview', () => { expect(within(graph).getByText(/Preview pass/)).toBeInTheDocument(); }); }); + +/** + * Zoom & pan (the marshaling deep-dive batch): wheel-at-cursor narrows the drawn span, the + * caption's Fit resets it, and — the regression that matters — pointer capture is DEFERRED + * until a real drag, so a plain click on a lap MARKER still selects the lap while zoomed + * (capturing on pointerdown retargeted the browser's synthesized click to the svg). + */ +describe('zoom & pan', () => { + function renderGraph(onselect = () => {}) { + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + pinSvgBox(svg); + return svg; + } + + it('wheel-in narrows the view (zoom note + Fit appear) and Fit restores the full span', async () => { + const svg = renderGraph(); + // No zoom yet: Fit and zoom-out are disabled, no zoom note. + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled(); + expect(screen.queryByText(/drag to pan/)).toBeNull(); + + // One wheel notch IN at mid-plot (45s). + await fireEvent.wheel(svg, { deltaY: -120, clientX: xForTime(45_000_000) }); + expect(screen.getByText(/drag to pan/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeEnabled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Reset zoom' })); + expect(screen.queryByText(/drag to pan/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled(); + }); + + it('the caption +/− buttons zoom without a wheel', async () => { + renderGraph(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect(screen.getByText(/drag to pan/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })); + // One notch out from one notch in: back to the full span. + expect(screen.queryByText(/drag to pan/)).toBeNull(); + }); + + it('a plain CLICK on a lap marker still selects the lap while zoomed (deferred capture)', async () => { + const onselect = vi.fn(); + const svg = renderGraph(onselect); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + + // A full click gesture on a marker: pointerdown lands on the svg (startPan) but must NOT + // capture — the click on the marker then selects the lap as normal. + const marker = screen.getByRole('button', { name: /Lap 2 at .* — select/ }); + const capture = vi.fn(); + (svg as unknown as SVGSVGElement).setPointerCapture = capture; + await fireEvent( + svg, + new MouseEvent('pointerdown', { bubbles: true, clientX: xForTime(80_000_000) }) + ); + await fireEvent(svg, new MouseEvent('pointerup', { bubbles: true })); + await fireEvent.click(marker); + expect(capture).not.toHaveBeenCalled(); + expect(onselect).toHaveBeenCalledTimes(1); + }); + + it('a real horizontal drag pans (captures) instead of clicking', async () => { + const svg = renderGraph(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + const noteBefore = screen.getByText(/drag to pan/).textContent; + + const capture = vi.fn(); + (svg as unknown as SVGSVGElement).setPointerCapture = capture; + const at = (x: number) => new MouseEvent('pointermove', { bubbles: true, clientX: x }); + await fireEvent( + svg, + new MouseEvent('pointerdown', { bubbles: true, clientX: xForTime(45_000_000) }) + ); + // Move well past the engage threshold, then further to pan. + await fireEvent(svg, at(xForTime(45_000_000) + 40)); + await fireEvent(svg, at(xForTime(45_000_000) + 140)); + await fireEvent(svg, new MouseEvent('pointerup', { bubbles: true })); + + expect(capture).toHaveBeenCalled(); + const noteAfter = screen.getByText(/drag to pan/).textContent; + expect(noteAfter).not.toBe(noteBefore); // the window actually moved + }); +}); diff --git a/frontend/apps/rd-console/tests/timers.test.ts b/frontend/apps/rd-console/tests/timers.test.ts index 9e28676..f7da6ca 100644 --- a/frontend/apps/rd-console/tests/timers.test.ts +++ b/frontend/apps/rd-console/tests/timers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Timer, TimerStatus } from '@gridfpv/types'; -import { isTimerConnected } from '../src/lib/timers.js'; +import type { Timer, TimerKind, TimerStatus } from '@gridfpv/types'; +import { isTimerConnected, kindLabel, kindSummary, kindTag, kindTone } from '../src/lib/timers.js'; /** Build a Timer with the given status (the only field `isTimerConnected` reads). */ function timerWith(status: TimerStatus): Timer { @@ -34,3 +34,18 @@ describe('isTimerConnected', () => { expect(isTimerConnected(timerWith('Error'))).toBe(false); }); }); + +describe('version skew: an unmodeled timer kind renders labeled, never crashes', () => { + // A NEWER Director may ship a kind this console build doesn't know (the RH-plugin pivot + // makes this likely). It must not mislabel as RotorHazard — and field access on + // `kind.Rotorhazard` must never throw. + const future = { RhPlugin: { url: 'http://rig:5055' } } as unknown as TimerKind; + it('tags, labels and tones it as unknown', () => { + expect(kindTag(future)).toBe('Unknown'); + expect(kindLabel(future)).toBe('RhPlugin'); + expect(kindTone(future)).toBe('neutral'); + }); + it('summarizes it with an update nudge instead of crashing', () => { + expect(kindSummary(future)).toContain('update the console'); + }); +}); diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts index 5497265..32ad038 100644 --- a/frontend/packages/components/src/format.ts +++ b/frontend/packages/components/src/format.ts @@ -51,7 +51,10 @@ export function formatMetric(metric: Metric): string { if ('BestConsecutiveMicros' in metric) return formatMicros(metric.BestConsecutiveMicros); if ('LastLapAt' in metric) return metric.LastLapAt === null ? '—' : 'banked'; if ('ReachedAt' in metric) return metric.ReachedAt === null ? '—' : 'reached'; - return '—'; + // Version skew: a NEWER Director may score under a metric variant this console build + // doesn't model. The pilot HAS a value — an em dash would read as DNF/no-time, so label + // the gap instead. + return 'unsupported'; } /** The medal token name for a 1-based finishing position, or `null` past 3rd. */ diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts index 2805f7e..1c29b4d 100644 --- a/frontend/packages/components/tests/format.test.ts +++ b/frontend/packages/components/tests/format.test.ts @@ -37,6 +37,11 @@ describe('formatMetric', () => { expect(formatMetric({ LastLapAt: 5 } as Metric)).toBe('banked'); expect(formatMetric({ ReachedAt: null } as Metric)).toBe('—'); }); + it('labels a metric variant this build does not model (version skew), not a DNF dash', () => { + expect(formatMetric({ FastestThreeMicros: 99_000_000 } as unknown as Metric)).toBe( + 'unsupported' + ); + }); }); describe('medalFor', () => { From a7039b69a5df09332891e1fa19a04a89fb0b7425 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 19:23:15 +0000 Subject: [PATCH 351/362] docs(audit): reconcile the deferred list with the release-polish batch Co-Authored-By: Claude Fable 5 --- docs/release-audit-2026-07.html | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html index de2da9d..9a44400 100644 --- a/docs/release-audit-2026-07.html +++ b/docs/release-audit-2026-07.html @@ -213,6 +213,14 @@

      4.4 Fuzz verdict

      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 @@ -220,17 +228,10 @@

        5. Deferred — recorded, deliberately not built

        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.
      • -
      • Orphan bridge pruning: a deleted event's bridge task holds a strong log - handle for the process lifetime — a resource leak, no correctness impact.
      • -
      • Presence-reconciler duplicate bindings within one poll batch — benign - (same key/value, last-wins fold).
      • -
      • Version-skew rendering: an additive server enum variant renders as a - blank metric cell / mislabeled timer kind on a stale console build. Mitigated by the - both-halves deploy rule and the new contract pins; a graceful-unknown rendering pass is - future polish.
      • -
      • StartProcedure form round-trip: the round form always writes - randomized-delay — lossless today (one mode exists), lossy the day a second - mode ships. The wire shape is now contract-pinned so that day is loud, not silent.
      • + + + +
      From c424a014a5ccfd2032a01d095fae9d8eee8d9d34 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 19:27:03 +0000 Subject: [PATCH 352/362] style: rustfmt Co-Authored-By: Claude Fable 5 --- crates/server/src/app.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index fee80cc..b2ab139 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1715,8 +1715,7 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 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 + && offset < crate::live_state::current_run_pass_ceiling(events, h) as u64 }) { window.push((offset, event.clone())); } From 0e4d0645626ce83d01cd1c313cfe6a7ec22fc4a7 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 21:52:25 +0000 Subject: [PATCH 353/362] wip: min-lap checkpoint (has mangled fixture literals, fixed next) Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 3 + crates/app/tests/race_flow.rs | 7 + crates/projection/src/lib.rs | 264 ++++++++++++++++++++++++++- crates/server/src/app.rs | 90 ++++++--- crates/server/src/control_handler.rs | 5 + crates/server/src/events.rs | 41 +++++ crates/server/src/live_state.rs | 25 ++- crates/server/src/round_engine.rs | 17 +- plugins/gridfpv_mock/__init__.py | 6 +- 9 files changed, 411 insertions(+), 47 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index c27eec7..381ed77 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -2560,6 +2560,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }; registry .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) @@ -2774,6 +2775,7 @@ mod tests { // 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) @@ -3010,6 +3012,7 @@ mod tests { protest_window: Some(ProtestWindow::After { micros: window_micros, }), + min_lap_secs: None, }; registry .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs index 8ed5ef1..1dfcf20 100644 --- a/crates/app/tests/race_flow.rs +++ b/crates/app/tests/race_flow.rs @@ -339,6 +339,7 @@ async fn round_driven_mock_race_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -444,6 +445,7 @@ async fn round_driven_mock_race_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -647,6 +649,7 @@ async fn fill_round_rejects_an_oversized_heat_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -787,6 +790,7 @@ async fn static_channel_balanced_qual_flow_e2e() { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .await; @@ -1026,6 +1030,7 @@ async fn fast_auto_event( // 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; @@ -1257,6 +1262,7 @@ async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() }), 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(); @@ -1470,6 +1476,7 @@ async fn two_open_practice_rounds_in_one_event_get_distinct_heats_e2e() { 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; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 3384cc2..051ca85 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -32,7 +32,7 @@ pub mod recalc; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use gridfpv_events::{ AdapterId, CompetitorRef, Event, HeatId, LogRef, Pass, PilotId, SignalHistory, SourceTime, @@ -128,7 +128,28 @@ pub struct VoidedPass { /// 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 { @@ -315,8 +336,19 @@ where corrected_and_voided_passes(events).0 } -/// One RD-voided pass as the fold emits it: `(pass offset, standing void event's offset, pass)`. -pub type VoidedEmit = (u64, u64, Pass); +/// [`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 @@ -544,13 +576,87 @@ where 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)); + 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). /// @@ -563,6 +669,15 @@ where /// 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, { @@ -570,7 +685,7 @@ where // 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(events); + 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 @@ -581,7 +696,7 @@ where // 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) in voided { + for (offset, void_offset, pass, reason) in voided { by_competitor .entry(CompetitorKey::from_pass(&pass)) .or_default(); @@ -592,6 +707,7 @@ where at: pass.at, pass_ref: LogRef(offset), void_ref: LogRef(void_offset), + reason, }); } @@ -1548,6 +1664,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(3), + reason: VoidReason::Marshal, }] ); @@ -1564,6 +1681,139 @@ mod marshaling_tests { 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, @@ -1590,6 +1840,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(5), + reason: VoidReason::Marshal, }] ); } @@ -1617,6 +1868,7 @@ mod marshaling_tests { at: SourceTime::from_micros(4_000_000), pass_ref: LogRef(1), void_ref: LogRef(3), + reason: VoidReason::Marshal, }] ); } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index b2ab139..0d70c1a 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -84,7 +84,8 @@ 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, marshaling_log, registrations, signal_trace, + 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; @@ -104,7 +105,8 @@ use crate::events::{ SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, }; use crate::live_state::{ - HeatSummary, heat_summaries, live_state, live_state_over, with_heat_timing, + 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; @@ -1761,20 +1763,41 @@ async fn snapshot_heat( 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(&heat_offsets), &stored)), - ) + 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( + 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 @@ -1792,31 +1815,21 @@ async fn snapshot_heat( // `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 = 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) - .map(|r| r.win_condition) - }) - }) + 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)) + 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 @@ -1854,6 +1867,10 @@ async fn snapshot_pilot( .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 @@ -2000,14 +2017,19 @@ 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. - let corrected = gridfpv_projection::corrected_passes(heat_offsets.iter().map(|(o, e)| (*o, e))); + // 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()) @@ -2022,6 +2044,15 @@ pub(crate) fn score_heat_window( ) } +/// 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. @@ -2185,6 +2216,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .expect("round adds (empty classes validate)"); diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 535efa4..2d44c64 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -3435,6 +3435,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3578,6 +3579,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3987,6 +3989,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -4165,6 +4168,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -4233,6 +4237,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index a3aba0d..0223e40 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -403,6 +403,16 @@ pub struct RoundDef { /// 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 @@ -840,6 +850,9 @@ pub struct NewRoundReq { #[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)] + pub min_lap_secs: Option, } /// The body of `PUT /events/{id}/rounds/{round}` — the editable fields of an existing round (race @@ -895,6 +908,9 @@ pub struct UpdateRoundReq { #[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)] + pub min_lap_secs: Option, } impl EventMeta { @@ -1414,6 +1430,7 @@ impl EventRegistry { 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. @@ -1443,6 +1460,7 @@ impl EventRegistry { // 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()); @@ -1538,6 +1556,7 @@ impl EventRegistry { 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 @@ -1563,6 +1582,11 @@ impl EventRegistry { 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(); @@ -1602,6 +1626,8 @@ impl EventRegistry { 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, }; @@ -2339,6 +2365,19 @@ fn validate_round_fields( /// 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, @@ -3113,6 +3152,7 @@ mod tests { grace_window: None, protest_window: None, } + min_lap_secs: None, } #[test] @@ -3177,6 +3217,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .expect("an open-practice round with no win condition saves"); diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index a4263c7..550fdac 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -45,7 +45,7 @@ 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, registrations}; +use gridfpv_projection::{CompetitorKey, lap_list_marshaled_with_floor, registrations}; use gridfpv_storage::StoredEvent; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -222,7 +222,7 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { .enumerate() .map(|(i, e)| (i as u64, e)) .collect(); - live_state_core(events, &window) + live_state_core(events, &window, None) } /// Fold a **windowed** log slice with its PRESERVED global offsets — the heat/class-scope @@ -232,17 +232,31 @@ pub fn live_state(events: &[Event]) -> LiveRaceState { /// `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) + 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)]) -> LiveRaceState { +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(); }; @@ -266,7 +280,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // 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( + let laps = lap_list_marshaled_with_floor( window .iter() .enumerate() @@ -287,6 +301,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState } }) .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 diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 6d4354a..0b5b00c 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -708,7 +708,12 @@ pub fn completed_heats(round: &RoundDef, events: &[Event]) -> Vec // 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); + 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). @@ -2519,7 +2524,7 @@ mod tests { // 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); + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); let dq_pilot = heat_result .places .iter() @@ -2691,7 +2696,7 @@ mod tests { 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); + 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 @@ -2719,7 +2724,7 @@ mod tests { "B", Penalty::Disqualify { reason: None }, )); - let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition); + let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); assert!( ruled .places @@ -2759,7 +2764,7 @@ mod tests { // Heat 1's result carries the DQ... let heat1 = - crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition); + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); let dq: Vec<&str> = heat1 .places .iter() @@ -2769,7 +2774,7 @@ mod tests { 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); + 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" diff --git a/plugins/gridfpv_mock/__init__.py b/plugins/gridfpv_mock/__init__.py index 4275550..a4f7ddb 100644 --- a/plugins/gridfpv_mock/__init__.py +++ b/plugins/gridfpv_mock/__init__.py @@ -102,10 +102,14 @@ def on_pass(data=None): val = int(round(baseline + (peak - baseline) * env)) n.history_values.append(val) n.history_times.append(t0 + i * (sample_ms / 1000.0)) - n.current_rssi = peak n.pass_peak_rssi = peak # Record the lap through RH's genuine pass pipeline (needs the race RACING). interface().intf_simulate_lap(node, 0) + # Land the live RSSI back at BASELINE, not the peak: a node parked at peak reads + # as "sitting on the gate" to RH's signal machinery at the NEXT race start, which + # fired an instant phantom crossing on every node — doubling the injected holeshot + # into a 4ms "lap 1" that shifted every real lap's number by one. + n.current_rssi = baseline ack("pass", node=node, peak=peak) except Exception as ex: # noqa: BLE001 nack("pass", ex) From 4ab25e4caf29713342c2996468b2393ea40f5d9f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 21:56:55 +0000 Subject: [PATCH 354/362] wip: min-lap backend complete (fold + threading + freeze + bindings) Co-Authored-By: Claude Fable 5 --- bindings/NewRoundReq.ts | 6 +++++- bindings/RoundDef.ts | 10 ++++++++++ bindings/UpdateRoundReq.ts | 6 +++++- bindings/VoidReason.ts | 6 ++++++ bindings/VoidedPass.ts | 12 +++++++++++- crates/server/src/events.rs | 6 +++++- crates/server/src/round_engine.rs | 6 ++++++ 7 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 bindings/VoidReason.ts diff --git a/bindings/NewRoundReq.ts b/bindings/NewRoundReq.ts index 65beb8c..6a1f277 100644 --- a/bindings/NewRoundReq.ts +++ b/bindings/NewRoundReq.ts @@ -77,4 +77,8 @@ grace_window?: GraceWindow, * [`ProtestWindow::Off`] (manual finalize only); supply [`ProtestWindow::After`] to arm the * auto-official timer. */ -protest_window?: ProtestWindow, }; +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs: number | null, }; diff --git a/bindings/RoundDef.ts b/bindings/RoundDef.ts index d92e99a..28d51f1 100644 --- a/bindings/RoundDef.ts +++ b/bindings/RoundDef.ts @@ -116,6 +116,16 @@ grace_window: GraceWindow, * 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 diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts index dda3eef..aca0c2b 100644 --- a/bindings/UpdateRoundReq.ts +++ b/bindings/UpdateRoundReq.ts @@ -67,4 +67,8 @@ grace_window?: GraceWindow, * The new protest window (marshaling Slice 5). Optional — omit for the default * [`ProtestWindow::Off`] (manual finalize only). */ -protest_window?: ProtestWindow, }; +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs: number | null, }; 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 index 622351b..4255d19 100644 --- a/bindings/VoidedPass.ts +++ b/bindings/VoidedPass.ts @@ -1,6 +1,7 @@ // 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`]). @@ -18,5 +19,14 @@ at: SourceTime, 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, }; +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/crates/server/src/events.rs b/crates/server/src/events.rs index 0223e40..f1315d3 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -3151,8 +3151,8 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, } - min_lap_secs: None, } #[test] @@ -3314,6 +3314,7 @@ mod tests { 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. @@ -3372,6 +3373,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ) .unwrap(); @@ -3489,6 +3491,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ); assert!(matches!(self_ref, Err(RoundError::Invalid(_)))); @@ -3536,6 +3539,7 @@ mod tests { start_procedure: None, grace_window: None, protest_window: None, + min_lap_secs: None, }, ); assert!(matches!(self_winners, Err(RoundError::Invalid(_)))); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 0b5b00c..e30b0da 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -1903,6 +1903,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -2328,6 +2329,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3121,6 +3123,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3281,6 +3284,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3692,6 +3696,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } @@ -3863,6 +3868,7 @@ mod tests { start_procedure: StartProcedure::default(), grace_window: default_grace_window(), protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, time_limit_secs: None, } } From a52000becc396c54d7fdaaf7a8552d2bfbeee4ee Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 22:07:21 +0000 Subject: [PATCH 355/362] feat(scoring): GridFPV-native minimum lap time (D26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timers are dumb emitters; GridFPV owns lap semantics. The trigger: a start-line double-detection gave every pilot a phantom 0.004s 'lap 1' — and RotorHazard HAD MinLapSec=10 set, but its default behavior records-and-highlights rather than discards, so the sub-min lap reached us with no invalid marker. Enforcement now lives in the system of record: - RoundDef.min_lap_secs (optional; the form seeds 5s for new rounds; 0/absent = off so pre-existing rounds keep bit-identical results; >600s rejected as a typo; FROZEN once the round has raced — it re-scores official heats, D24). - The corrected-passes fold auto-suppresses a RAW pass that would close an under-floor lap (keep-first per burst) — applied identically in the lap list, the live view, and every scoring path via corrected_passes_with_floor / lap_list_marshaled_with_floor / live_state_over_with_floor, so the score and the lap list can never disagree about an echo. The log keeps every raw pass: suppression is a view-fold rule, never data destruction. - The removal record grows a reason (VoidReason::Marshal | UnderMinLap): auto- removed crossings render 'under min lap, auto-removed' beside marshal voids, and Restore branches — a marshal void restores via void-the-void; a floor suppression restores via an AdjustLap re-asserting the raw instant (an explicit ruling always outranks the floor, so inserted/split/re-timed passes are exempt by construction; a whoop track's genuine 2s laps stay scoreable). - The pilot ws view (whole-log, cross-round) stays unfloored by design — rounds carry different floors; the per-heat views are the authoritative surfaces. - Rides along: the gridfpv_mock plugin lands node RSSI back at BASELINE after each injected pass — parked-at-peak nodes read as 'sitting on the gate' at the NEXT race start, which is what fired the phantom double in the first place. Docs: decisions.html D26 + marshaling.html note. Tests: 5 fold suppression cases, the scoring seam (a 4ms echo can no longer be a best lap), freeze + normalization + bounds, console removal-row label + Restore command, rounds-form round-trip. Co-Authored-By: Claude Fable 5 --- bindings/NewRoundReq.ts | 2 +- bindings/UpdateRoundReq.ts | 2 +- crates/projection/src/lib.rs | 20 +++-- crates/server/src/app.rs | 49 ++++++++++++ crates/server/src/events.rs | 75 +++++++++++++++++++ crates/server/src/round_engine.rs | 6 +- docs/decisions.html | 28 +++++++ docs/marshaling.html | 7 ++ .../rd-console/src/screens/EventRounds.svelte | 13 ++++ .../rd-console/src/screens/Marshaling.svelte | 31 ++++++-- .../apps/rd-console/tests/EventRounds.test.ts | 20 +++++ .../rd-console/tests/MarshalingScreen.test.ts | 28 ++++++- frontend/packages/types/src/generated.ts | 1 + 13 files changed, 262 insertions(+), 20 deletions(-) diff --git a/bindings/NewRoundReq.ts b/bindings/NewRoundReq.ts index 6a1f277..1bdddf9 100644 --- a/bindings/NewRoundReq.ts +++ b/bindings/NewRoundReq.ts @@ -81,4 +81,4 @@ protest_window?: ProtestWindow, /** * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. */ -min_lap_secs: number | null, }; +min_lap_secs?: number, }; diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts index aca0c2b..2f6e0e7 100644 --- a/bindings/UpdateRoundReq.ts +++ b/bindings/UpdateRoundReq.ts @@ -71,4 +71,4 @@ protest_window?: ProtestWindow, /** * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. */ -min_lap_secs: number | null, }; +min_lap_secs?: number, }; diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 051ca85..b5d3d2e 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -339,7 +339,10 @@ where /// [`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)> +pub fn corrected_passes_with_floor<'a, I>( + events: I, + min_lap_micros: Option, +) -> Vec<(u64, Pass)> where I: IntoIterator, { @@ -637,8 +640,8 @@ where 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); + 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 { @@ -1688,9 +1691,9 @@ mod marshaling_tests { // 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", 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)); @@ -1739,7 +1742,10 @@ mod marshaling_tests { .find(|c| c.competitor.competitor.0 == "A") .unwrap(); assert_eq!( - cl.laps.iter().map(|l| l.duration_micros).collect::>(), + 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" ); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 0d70c1a..a0be296 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -2138,6 +2138,55 @@ mod tests { ] } + /// 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. diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index f1315d3..e22adde 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -852,6 +852,7 @@ pub struct NewRoundReq { pub protest_window: Option, /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. #[serde(default)] + #[ts(optional)] pub min_lap_secs: Option, } @@ -910,6 +911,7 @@ pub struct UpdateRoundReq { pub protest_window: Option, /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. #[serde(default)] + #[ts(optional)] pub min_lap_secs: Option, } @@ -3155,6 +3157,79 @@ mod tests { } } + #[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(); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index e30b0da..bdd6a6c 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2698,7 +2698,8 @@ mod tests { 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); + 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 @@ -2726,7 +2727,8 @@ mod tests { "B", Penalty::Disqualify { reason: None }, )); - let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); + let ruled = + crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); assert!( ruled .places diff --git a/docs/decisions.html b/docs/decisions.html index 7310476..4055462 100644 --- a/docs/decisions.html +++ b/docs/decisions.html @@ -757,6 +757,34 @@

      D25 · The removal record: voids and re-detection share one truth; 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.
      +
      +

      Internal design documentation · ← back to docs index diff --git a/docs/marshaling.html b/docs/marshaling.html index 6651421..27c35a6 100644 --- a/docs/marshaling.html +++ b/docs/marshaling.html @@ -258,6 +258,13 @@

      3.3 Differentiator — the governance layer nobody has

      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

      diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 2e30efe..b522fdf 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -552,6 +552,10 @@ let startMinSeconds = $state(2); // randomized start hold: shortest, in seconds (→ min_delay_ms) let startMaxSeconds = $state(5); // randomized start hold: longest, in seconds (→ max_delay_ms) let graceSeconds = $state(30); // grace window after the win condition, in seconds + // Min lap time (D26): raw crossings that would close a shorter lap are auto-removed (a gate + // reflection / double-detection), marshal-restorable. 0 = off; NEW rounds seed the + // field-standard 5s so a double-fire never fabricates a 0.004s best lap out of the box. + let minLapSeconds = $state(5); // ── Protest window (marshaling Slice 5) ────────────────────────────────────── // The **auto-official timer**, in seconds. 0 (the default) = OFF: the result stays provisional // (Unofficial) until the RD finalizes manually — today's behaviour. A positive value arms the @@ -716,6 +720,7 @@ startMinSeconds = 2; startMaxSeconds = 5; graceSeconds = 30; + minLapSeconds = 5; protestSeconds = 0; // off by default — manual finalize only timeLimitMinutes = ''; // blank = no limit } @@ -796,6 +801,7 @@ const grace = round.grace_window; graceSeconds = grace && typeof grace !== 'string' ? Math.round(grace.Duration.micros / 1_000_000) : 30; + minLapSeconds = round.min_lap_secs ?? 0; // Protest window (marshaling Slice 5): reflect an `After { micros }` back as seconds; `Off` (or a // round that predates the field) reads back as 0 (the timer disabled — manual finalize only). @@ -1035,6 +1041,7 @@ channel_mode: channelMode, staging_timer_secs: buildStagingSecs(), start_procedure: buildStartProcedure(), + min_lap_secs: Math.max(0, Math.round(Number(minLapSeconds) || 0)), grace_window: buildGraceWindow(), protest_window: buildProtestWindow() }; @@ -1737,6 +1744,12 @@ aria-label="Grace window seconds" /> + + + { + /** RESTORE a removed pass. A marshal-voided pass is undone by void-the-void (targeting the + * standing removal event); a floor-suppressed pass (UnderMinLap, D26) is BLESSED by an + * AdjustLap re-asserting its own raw instant — an explicit ruling outranks the floor, so + * the fold exempts it and the pass returns to the chain. */ + function doRestorePass(v: { + void_ref: number; + pass_ref: number; + at: number; + reason: VoidReason; + }): Promise { return submitCorrection(async () => { - const ack = await session.send(voidDetectionCommand(v.void_ref)); + const ack = await session.send( + v.reason === 'UnderMinLap' + ? adjustLapCommand(v.pass_ref, v.at) + : voidDetectionCommand(v.void_ref) + ); if (ack.ok) await afterCorrection(); }); } @@ -1076,7 +1087,9 @@

    • removed pass at {formatMicros(v.at)}s — stays removed{v.reason === 'UnderMinLap' + ? `crossing at ${formatMicros(v.at)}s — under min lap, auto-removed` + : `removed pass at ${formatMicros(v.at)}s — stays removed`} {#if canCorrect} Remove {/if}
    • @@ -1132,40 +1135,35 @@ corrections box, moved to where the lap is. -->
    • Save time - Split
    • diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 4117285..b06f5ca 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -39,16 +39,15 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 14 } }); }); - it('splits the selected lap at the entered time, targeting its end_ref', async () => { + it('splits the selected lap at its MIDPOINT — no time input needed (a missed crossing)', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); render(Marshaling, { session }); - // Marshal one pilot at a time: show BOB, then select his only lap (end_ref 13). + // Marshal one pilot at a time: show BOB, then select his only lap (end_ref 13, 0→43.0s). await fireEvent.change(screen.getByLabelText('Pilot to marshal'), { target: { value: 'BOB' } }); await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*43\.000/ })); - await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '21' } }); await fireEvent.click(screen.getByRole('button', { name: 'Split' })); - expect(sendSpy).toHaveBeenCalledWith({ SplitLap: { target: 13, at: 21_000_000 } }); + expect(sendSpy).toHaveBeenCalledWith({ SplitLap: { target: 13, at: 21_500_000 } }); }); it('marshals a different heat WITHOUT moving Race Control’s current heat', async () => { @@ -90,14 +89,20 @@ describe('Marshaling (Slice 3)', () => { expect(movedCurrent).toBeUndefined(); }); - it('edits the selected lap time (AdjustLap on end_ref)', async () => { + it('the editor pre-fills the lap time; Save arms on change and re-times the closing pass', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); render(Marshaling, { session }); + // ALICE lap 1: 41.000s (start at 0, end_ref 12). The input opens PRE-FILLED with it and + // Save stays disabled until the value changes — no free-typed instants. await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*41\.000/ })); - await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '40' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Edit time' })); - // ALICE lap 1 end_ref = 12. + expect((screen.getByLabelText('Lap time') as HTMLInputElement).value).toBe('41'); + expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled(); + + await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '40' } }); + expect(screen.getByRole('button', { name: 'Save time' })).toBeEnabled(); + await fireEvent.click(screen.getByRole('button', { name: 'Save time' })); + // New duration 40s from the lap's start (0) → the closing pass re-times to 40.0s. expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 12, at: 40_000_000 } }); }); @@ -407,8 +412,7 @@ describe('Marshaling (Slice 3)', () => { // …but every result-changing surface is gone: the per-row Remove + the inline editor… expect(screen.queryByRole('button', { name: /Remove lap/ })).toBeNull(); expect(screen.queryByRole('button', { name: 'Split' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Edit time' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Save time' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Throw out' })).toBeNull(); // …and the inline Add-lap control. expect(screen.queryByRole('button', { name: '+ Add lap' })).toBeNull(); @@ -528,29 +532,27 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledTimes(1); }); - it('requires a positive time for Split / Edit time / Insert after (never sends at=0)', async () => { + it('Save time never sends a degenerate value: disabled pristine, emptied, or zeroed', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList }); render(Marshaling, { session }); - // Select a lap with the time input still at its 0 default: the time-based corrections stay - // DISABLED (with the explaining title) — an `AdjustLap at: 0` wrecks the whole lap chain. + // Pristine (pre-filled with the lap's own time): nothing to save. await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*41\.000/ })); - for (const name of ['Split', 'Edit time', 'Insert after']) { - const button = screen.getByRole('button', { name }); - expect(button).toBeDisabled(); - expect(button).toHaveAttribute('title', 'Enter a positive time (s) first'); - } - await fireEvent.click(screen.getByRole('button', { name: 'Edit time' })); - expect(sendSpy).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled(); + // Insert after is GONE (the + Add lap footer covers insertion). + expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull(); - // An EMPTIED input (binds null) is refused the same way. - await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '' } }); - expect(screen.getByRole('button', { name: 'Edit time' })).toBeDisabled(); + // An EMPTIED input (binds null) and a zero both keep Save disabled — an + // `AdjustLap at: start+0` wrecks the lap chain. + await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '' } }); + expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled(); + await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '0' } }); + expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled(); + expect(sendSpy).not.toHaveBeenCalled(); - // A positive time enables them and the command carries it. - await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '40' } }); - expect(screen.getByRole('button', { name: 'Edit time' })).toBeEnabled(); - await fireEvent.click(screen.getByRole('button', { name: 'Edit time' })); + // A changed positive time arms Save and the command carries the re-timed instant. + await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '40' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save time' })); expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 12, at: 40_000_000 } }); }); From 2d38b7b4b2990f3ca67a17aa6ef9cdd58e2aa3c9 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sun, 5 Jul 2026 00:30:29 +0000 Subject: [PATCH 360/362] =?UTF-8?q?feat(marshaling):=20the=20page=20reads?= =?UTF-8?q?=20as=20two=20scopes=20=E2=80=94=20pilot=20marshaling=20vs=20he?= =?UTF-8?q?at=20rulings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design call with the RD: protests/rulings STAY on the marshaling page (rulings belong next to the evidence — a DQ or a protest resolution is made looking at the laps, the trace, and the audit, and a separate page would make every adjudication a context switch on the same heat). Revisit a dedicated cross-heat protest queue if/when pilot-filed protests arrive. What changes is the SEAM: the per-pilot surfaces (trace, tune, laps & corrections) and the heat-scoped surfaces (penalties, protests, result lifecycle, void) now sit under two labeled region heads with a deliberately heavy divider between them — sunlit-laptop legible, and the heat region reads as a distinct zone rather than more pilot UI. Co-Authored-By: Claude Fable 5 --- .../rd-console/src/screens/Marshaling.svelte | 45 +++++++++++++++++++ .../rd-console/tests/MarshalingScreen.test.ts | 11 +++++ 2 files changed, 56 insertions(+) diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 0ed2f0e..8ac1a9e 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -933,6 +933,14 @@ {/if} + +

      + Pilot marshaling + the shown pilot's laps, trace & corrections +

      + {#if hasShownTrace && shownTrace} {@const tuning = canControl && tuneTrace != null && tuneKeyCurrent} +
      +

      + Heat rulings & protests + whole-heat scope — penalties, protests & the result +

      {#if resultLocked} + - -
      - - - -

      - A token is only needed for privileged actions (creating an event, running a heat, registering, - marshaling). You'll be asked automatically when one is required. -

      -
      - {#snippet footer()} - {#if session.hasToken} - - {/if} - - {/snippet} -
      -
    {row.position}{resolveName(row.competitor)}{resolveName(row.competitor)}{@render auditLink(row.competitor)}
    {row.position}{resolveName(row.competitor)}{resolveName(row.competitor)}{@render auditLink(row.competitor)} {formatMicros(row.best_lap_micros)}{ttMetricValue(row)}