diff --git a/.env.example b/.env.example index 3bf5dd1..f21317f 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,6 @@ STUN_HOST=stun.example.com # POSTGRES_DB=opencade # RFC 8489 server as a numeric IP:port. Both variables should identify the same service. # OPENCADE_STUN_SERVER=203.0.113.10:3478 +# Evidence-gated native route promotion. Leave disabled until physical-host evidence qualifies. +# OPENCADE_EVIDENCE_TCP_TUNNEL=false +# ROUTE_POLICY_MIN_ATTEMPTS=10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c0c2e..a082c4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,15 @@ jobs: - name: Build Windows LAN alpha tools run: cargo build -p opencade-networking --bins --release --locked + - name: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Build deterministic OpenCade test core + shell: pwsh + run: .\scripts\alpha\Build-TestCore.ps1 + - name: Build Windows desktop alpha run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7337e44..fd1cd48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,65 @@ permissions: id-token: write attestations: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + validate: + name: Validate tagged commit + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: opencade + POSTGRES_PASSWORD: opencade + POSTGRES_DB: opencade_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U opencade -d opencade_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.98" + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: pnpm install --frozen-lockfile + - name: Require a matching semantic version + shell: bash + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "${GITHUB_REF_NAME#v}" = "$(node -p "require('./package.json').version")" + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace --locked + env: + DATABASE_URL: postgres://opencade:opencade@localhost:5432/opencade_test + SESSION_SECRET: release-test-secret-at-least-32-bytes + - run: pnpm format:check + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + - name: Audit Rust dependencies + run: | + cargo install cargo-audit --locked + cargo audit --deny warnings + - run: pnpm audit --prod --audit-level high + windows: runs-on: windows-latest + needs: validate steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -26,6 +82,13 @@ jobs: - run: pnpm build - run: cargo build -p opencade-networking --bins --release --locked - run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked + - name: Configure MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Build deterministic OpenCade test core + shell: pwsh + run: .\scripts\alpha\Build-TestCore.ps1 - name: Require and import Windows signing certificate shell: pwsh env: @@ -49,7 +112,8 @@ jobs: "apps\client\src-tauri\target\release\opencade-client.exe", "target\release\opencade-match-probe.exe", "target\release\opencade-match-verify.exe", - "target\release\opencade-alpha-summary.exe" + "target\release\opencade-alpha-summary.exe", + "fixtures\libretro\opencade-test-core\build\opencade_test_libretro.dll" ) foreach ($executable in $executables) { & $signTool sign /sha1 $env:OPENCADE_CERT_THUMBPRINT /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $executable diff --git a/Cargo.lock b/Cargo.lock index 8ddcf7b..3a17292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,9 +198,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", diff --git a/README.md b/README.md index 98e0d4c..cc1d765 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > **Open-source arcade netplay — a clean-room, community-owned alternative for low-latency rollback matchmaking and emulation.** -OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch, direct UDP/STUN/hole-punching, authenticated readiness-probe relay fallback, and an experimental native-process RetroArch adapter. Standalone FBNeo netplay, physical cross-NAT results, behavior classification, and real two-machine RetroArch play remain explicitly unproven. +OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, an original no-ROM libretro Proof-of-Play fixture, safe local FBNeo launch, direct UDP/STUN/hole-punching, authenticated readiness-probe relay fallback, and an experimental native-process RetroArch adapter. Standalone FBNeo netplay, physical cross-NAT results, behavior classification, and real two-machine RetroArch play remain explicitly unproven. ## ☕ Support OpenCade — Keep Arcade Netplay Free & Independent @@ -119,6 +119,7 @@ OpenCade/ │ ├── adr/ # Architecture decision records │ ├── alpha/ # LAN test and match-report procedures │ ├── IMPLEMENTATION_STATUS.md # Verified scope and explicit non-claims +│ ├── OPPORTUNITIES_2026-08-28.md # 20 screened bets + one recommended keystone │ └── reference-fightcade-install.md # D:/Fightcade read-only notes (never copied) ├── docker/ │ └── (compose at root: `docker-compose.yml` — see also `docker/` if present) diff --git a/adapters/retroarch/src/lib.rs b/adapters/retroarch/src/lib.rs index 63b2804..d32bc70 100644 --- a/adapters/retroarch/src/lib.rs +++ b/adapters/retroarch/src/lib.rs @@ -27,6 +27,52 @@ const FBNEO_CORE: &str = "fbneo_libretro.dylib"; #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] const FBNEO_CORE: &str = "fbneo_libretro.so"; +#[cfg(target_os = "windows")] +const TEST_CORE: &str = "opencade_test_libretro.dll"; +#[cfg(target_os = "macos")] +const TEST_CORE: &str = "opencade_test_libretro.dylib"; +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +const TEST_CORE: &str = "opencade_test_libretro.so"; + +pub const TEST_GAME_ID: &str = "opencade_test"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CoreProfile { + Fbneo, + OpenCadeTest, +} + +impl CoreProfile { + fn for_game(game_id: &str) -> Self { + if game_id == TEST_GAME_ID { + Self::OpenCadeTest + } else { + Self::Fbneo + } + } + + fn core_name(self) -> &'static str { + match self { + Self::Fbneo => FBNEO_CORE, + Self::OpenCadeTest => TEST_CORE, + } + } + + fn adapter_id(self) -> &'static str { + match self { + Self::Fbneo => "retroarch_fbneo", + Self::OpenCadeTest => "retroarch_test", + } + } + + fn content_extension(self) -> &'static str { + match self { + Self::Fbneo => "zip", + Self::OpenCadeTest => "ocade", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CompatibilityFingerprint { pub retroarch_version: Option, @@ -55,8 +101,8 @@ impl RetroarchAdapter { self.install_root.join(EXECUTABLE) } - fn core(&self) -> PathBuf { - self.install_root.join("cores").join(FBNEO_CORE) + fn core(&self, profile: CoreProfile) -> PathBuf { + self.install_root.join("cores").join(profile.core_name()) } fn installed_version(&self) -> Option { @@ -67,28 +113,51 @@ impl RetroarchAdapter { } pub fn fingerprint(&self, rom_path: &Path) -> Result { + self.fingerprint_for_game("fbneo", rom_path) + } + + pub fn fingerprint_for_game( + &self, + game_id: &str, + content_path: &Path, + ) -> Result { + let profile = CoreProfile::for_game(game_id); let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; - let rom = canonicalize_below(rom_path, &self.rom_root())?; + let core = canonicalize_below(&self.core(profile), &self.install_root)?; + let content = canonicalize_below(content_path, &self.rom_root())?; Ok(CompatibilityFingerprint { retroarch_version: self.installed_version(), executable_sha256: sha256_file(&executable)?, core_sha256: sha256_file(&core)?, - content_sha256: sha256_file(&rom)?, + content_sha256: sha256_file(&content)?, }) } + pub fn adapter_id_for_game(&self, game_id: &str) -> &'static str { + CoreProfile::for_game(game_id).adapter_id() + } + pub fn match_launch_spec( &self, rom_path: &Path, descriptor: &MatchDescriptor, ) -> Result { + self.match_launch_spec_for_game("fbneo", rom_path, descriptor) + } + + pub fn match_launch_spec_for_game( + &self, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + let profile = CoreProfile::for_game(game_id); self.prepare_match(descriptor)?; let transport = descriptor.transport_lease()?; - self.validate(rom_path)?; + self.validate_profile(profile, content_path)?; let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; - let rom = canonicalize_below(rom_path, &self.rom_root())?; + let core = canonicalize_below(&self.core(profile), &self.install_root)?; + let content = canonicalize_below(content_path, &self.rom_root())?; let current_dir = executable .parent() .ok_or_else(|| AdapterError::Validation("RetroArch has no parent directory".into()))? @@ -112,7 +181,7 @@ impl RetroarchAdapter { OsString::from("--frames"), OsString::from(descriptor.input_delay_frames.to_string()), OsString::from("--verbose"), - rom.into_os_string(), + content.into_os_string(), ]); Ok(LaunchSpec { executable, @@ -129,6 +198,58 @@ impl RetroarchAdapter { ) -> Result { launcher.spawn(&self.match_launch_spec(rom_path, descriptor)?) } + + pub fn launch_match_for_game_with( + &self, + launcher: &L, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + launcher.spawn(&self.match_launch_spec_for_game(game_id, content_path, descriptor)?) + } + + pub fn launch_match_for_game( + &self, + game_id: &str, + content_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + self.launch_match_for_game_with(&StdProcessLauncher, game_id, content_path, descriptor) + } + + fn validate_profile( + &self, + profile: CoreProfile, + content_path: &Path, + ) -> Result { + let executable = self.executable(); + let core = self.core(profile); + if !executable.is_file() || !core.is_file() { + return Err(AdapterError::NotDetected(format!( + "expected {EXECUTABLE} and cores/{} below the RetroArch root", + profile.core_name() + ))); + } + canonicalize_below(content_path, &self.rom_root())?; + if content_path.extension().and_then(|value| value.to_str()) + != Some(profile.content_extension()) + { + return Err(AdapterError::Validation(format!( + "{} content must use .{}", + profile.adapter_id(), + profile.content_extension() + ))); + } + let mut report = ValidationReport::valid(); + if self.installed_version().is_none() { + report.warnings.push( + "VERSION.txt is missing; exact RetroArch version will not appear in evidence" + .into(), + ); + } + Ok(report) + } } impl EmulatorAdapter for RetroarchAdapter { @@ -160,21 +281,7 @@ impl EmulatorAdapter for RetroarchAdapter { } fn validate(&self, rom_path: &Path) -> Result { - self.detect(&self.install_root)?; - canonicalize_below(rom_path, &self.rom_root())?; - if rom_path.extension().and_then(|value| value.to_str()) != Some("zip") { - return Err(AdapterError::Validation( - "RetroArch FBNeo content must be a .zip file".into(), - )); - } - let mut report = ValidationReport::valid(); - if self.installed_version().is_none() { - report.warnings.push( - "VERSION.txt is missing; exact RetroArch version will not appear in evidence" - .into(), - ); - } - Ok(report) + self.validate_profile(CoreProfile::Fbneo, rom_path) } fn get_version(&self) -> Result { @@ -185,7 +292,7 @@ impl EmulatorAdapter for RetroarchAdapter { fn launch(&self, rom_path: &Path) -> Result { self.validate(rom_path)?; let executable = canonicalize_below(&self.executable(), &self.install_root)?; - let core = canonicalize_below(&self.core(), &self.install_root)?; + let core = canonicalize_below(&self.core(CoreProfile::Fbneo), &self.install_root)?; let rom = canonicalize_below(rom_path, &self.rom_root())?; let current_dir = executable .parent() @@ -264,7 +371,11 @@ mod tests { std::fs::create_dir_all(root.join("ROMs")).expect("ROM directory"); std::fs::write(root.join(EXECUTABLE), b"retroarch").expect("executable"); std::fs::write(root.join("cores").join(FBNEO_CORE), b"fbneo-core").expect("core"); + std::fs::write(root.join("cores").join(TEST_CORE), b"opencade-test-core") + .expect("test core"); std::fs::write(root.join("ROMs/sfiii3.zip"), b"content").expect("content"); + std::fs::write(root.join("ROMs/opencade_test.ocade"), b"OPENCADE-TEST-1\n") + .expect("test content"); std::fs::write(root.join("VERSION.txt"), "1.22.0\n").expect("version"); root } @@ -339,6 +450,31 @@ mod tests { std::fs::remove_dir_all(root).expect("remove fixture"); } + #[test] + fn fingerprints_and_launches_the_no_rom_test_profile() { + let root = fixture(); + let adapter = RetroarchAdapter::new(&root); + let content = root.join("ROMs/opencade_test.ocade"); + let fingerprint = adapter + .fingerprint_for_game(TEST_GAME_ID, &content) + .expect("test fingerprint"); + assert_eq!(adapter.adapter_id_for_game(TEST_GAME_ID), "retroarch_test"); + assert_eq!(fingerprint.content_sha256.len(), 64); + let spec = adapter + .match_launch_spec_for_game(TEST_GAME_ID, &content, &descriptor(PeerRole::Host)) + .expect("test launch spec"); + assert!(spec.args.iter().any(|arg| { + Path::new(arg) + .file_name() + .is_some_and(|name| name == TEST_CORE) + })); + assert_eq!( + spec.args.last(), + Some(&content.canonicalize().expect("content").into_os_string()) + ); + std::fs::remove_dir_all(root).expect("remove fixture"); + } + #[test] fn rejects_content_outside_the_rom_root() { let root = fixture(); diff --git a/apps/client/src-tauri/src/commands/fs.rs b/apps/client/src-tauri/src/commands/fs.rs index ad39086..be0147e 100644 --- a/apps/client/src-tauri/src/commands/fs.rs +++ b/apps/client/src-tauri/src/commands/fs.rs @@ -1,4 +1,5 @@ use opencade_adapter_fbneo::FbneoAdapter; +use opencade_adapter_retroarch::RetroarchAdapter; use opencade_emulator_sdk::EmulatorAdapter; use serde::Serialize; use std::path::PathBuf; @@ -37,8 +38,32 @@ pub fn scan_fbneo_rom(root: PathBuf, game_id: &str) -> Result Result { - scan_fbneo_rom(super::process::fbneo_root(&app)?, &game_id) +pub async fn scan_game(app: tauri::AppHandle, game_id: String) -> Result { + if game_id == opencade_adapter_retroarch::TEST_GAME_ID { + let root = super::process::retroarch_root(&app)?; + return tokio::task::spawn_blocking(move || { + let adapter = RetroarchAdapter::new(&root); + let content = root.join("ROMs").join("opencade_test.ocade"); + match adapter.fingerprint_for_game(&game_id, &content) { + Ok(_) => Ok(GameAvailability { + game_id, + available: true, + warnings: Vec::new(), + }), + Err(error) => Ok(GameAvailability { + game_id, + available: false, + warnings: vec![error.to_string()], + }), + } + }) + .await + .map_err(|error| format!("game scan worker failed: {error}"))?; + } + let root = super::process::fbneo_root(&app)?; + tokio::task::spawn_blocking(move || scan_fbneo_rom(root, &game_id)) + .await + .map_err(|error| format!("game scan worker failed: {error}"))? } #[cfg(test)] diff --git a/apps/client/src-tauri/src/commands/process.rs b/apps/client/src-tauri/src/commands/process.rs index 7d7b02c..7a637fd 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -25,7 +25,7 @@ pub fn fbneo_root(app: &tauri::AppHandle) -> Result { .map_err(|_| "application resource directory is unavailable".into()) } -fn retroarch_root(app: &tauri::AppHandle) -> Result { +pub(crate) fn retroarch_root(app: &tauri::AppHandle) -> Result { if let Some(root) = std::env::var_os("OPENCADE_RETROARCH_ROOT") { let root = PathBuf::from(root); if !root.is_absolute() { @@ -104,16 +104,17 @@ pub async fn retroarch_preflight( validate_game_id(&game_id)?; let root = retroarch_root(&app)?; let adapter = RetroarchAdapter::new(&root); - let rom = root.join("ROMs").join(format!("{game_id}.zip")); - let fingerprint = tokio::task::spawn_blocking(move || adapter.fingerprint(&rom)) - .await - .map_err(|_| "compatibility preflight worker failed".to_string())? - .map_err(|error| error.to_string())?; + let content = content_path(&root, &game_id); + let adapter_id = adapter.adapter_id_for_game(&game_id); + let fingerprint = + tokio::task::spawn_blocking(move || adapter.fingerprint_for_game(&game_id, &content)) + .await + .map_err(|_| "compatibility preflight worker failed".to_string())? + .map_err(|error| error.to_string())?; let native_port_available = - ensure_native_port_available("0.0.0.0:55435".parse().expect("static socket address")) - .is_ok(); + ensure_native_port_available(std::net::SocketAddr::from(([0, 0, 0, 0], 55_435))).is_ok(); Ok(RetroarchPreflight { - adapter: "retroarch_fbneo", + adapter: adapter_id, emulator_version: fingerprint.retroarch_version, executable_sha256: fingerprint.executable_sha256, core_sha256: fingerprint.core_sha256, @@ -168,9 +169,9 @@ pub async fn launch_retroarch_match( validate_game_id(&authorized.game_id)?; let root = retroarch_root(&app)?; let adapter = RetroarchAdapter::new(&root); - let rom = root - .join("ROMs") - .join(format!("{}.zip", authorized.game_id)); + let game_id = authorized.game_id.clone(); + let content = content_path(&root, &game_id); + let adapter_id = adapter.adapter_id_for_game(&game_id); let descriptor = MatchDescriptor { room_id: authorized.room_id.clone(), game_id: authorized.game_id, @@ -190,8 +191,8 @@ pub async fn launch_retroarch_match( ensure_native_port_available(descriptor.local_endpoint)?; } let (fingerprint, child) = tokio::task::spawn_blocking(move || { - let fingerprint = adapter.fingerprint(&rom)?; - let child = adapter.launch_match(&rom, &descriptor)?; + let fingerprint = adapter.fingerprint_for_game(&game_id, &content)?; + let child = adapter.launch_match_for_game(&game_id, &content, &descriptor)?; Ok::<_, opencade_emulator_sdk::AdapterError>((fingerprint, child)) }) .await @@ -206,12 +207,21 @@ pub async fn launch_retroarch_match( let pid = register_child(&app, &state, child, Some(callback))?; Ok(RetroarchMatchLaunch { pid, - adapter: "retroarch_fbneo", + adapter: adapter_id, room_id, fingerprint, }) } +fn content_path(root: &std::path::Path, game_id: &str) -> PathBuf { + let extension = if game_id == opencade_adapter_retroarch::TEST_GAME_ID { + "ocade" + } else { + "zip" + }; + root.join("ROMs").join(format!("{game_id}.{extension}")) +} + #[tauri::command] pub fn stop_game(state: tauri::State<'_, ProcessState>, pid: u32) -> Result<(), String> { let child = state diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index abd6850..3a9b87b 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -4,7 +4,6 @@ import type { Envelope, MatchEndpointPayload, MatchProbeCompletedPayload, - RoomPayload, RoomSnapshotPayload, } from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; @@ -12,7 +11,7 @@ import { ApiError, api, configureApiBase } from "./lib/api"; import { parseMatchCompletion, parseMatchEndpoint } from "./lib/match"; import { cancelMatchProbe, loadRuntimeConfig } from "./lib/native"; import { useSessionStore } from "./lib/store"; -import { newestSnapshot } from "./lib/snapshot"; +import { isRoomPayload, isRoomSnapshot, newestSnapshot } from "./lib/snapshot"; import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; import Games from "./routes/Games"; @@ -117,6 +116,13 @@ export default function App() { ); queryClient.setQueryData(["room", snapshot.room.id], snapshot.room); } + if (message.type === "room.deadline.expired") { + const roomId = roomIdFromPayload(message.payload); + if (roomId) { + void queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + void queryClient.invalidateQueries({ queryKey: ["room-snapshot", roomId] }); + } + } }); socket.connect(); return () => { @@ -231,50 +237,6 @@ export default function App() { ); } -function isRoomPayload(payload: unknown): payload is RoomPayload { - const states = new Set([ - "waiting", - "ready", - "challenging", - "connecting", - "playing", - "finished", - "cancelled", - ]); - return ( - typeof payload === "object" && - payload !== null && - typeof Reflect.get(payload, "id") === "string" && - typeof Reflect.get(payload, "game_id") === "string" && - typeof Reflect.get(payload, "host_id") === "string" && - (Reflect.get(payload, "guest_id") === null || - typeof Reflect.get(payload, "guest_id") === "string") && - states.has(String(Reflect.get(payload, "state"))) - ); -} - -function isRoomSnapshot(payload: unknown): payload is RoomSnapshotPayload { - const barrier = - typeof payload === "object" && payload !== null ? Reflect.get(payload, "barrier") : null; - return ( - typeof payload === "object" && - payload !== null && - isRoomPayload(Reflect.get(payload, "room")) && - Number.isSafeInteger(Reflect.get(payload, "revision")) && - Number.isSafeInteger(Reflect.get(payload, "preflight_count")) && - typeof Reflect.get(payload, "compatibility_matched") === "boolean" && - typeof barrier === "object" && - barrier !== null && - typeof Reflect.get(barrier, "room_id") === "string" && - Number.isSafeInteger(Reflect.get(barrier, "ready_count")) && - Number.isSafeInteger(Reflect.get(barrier, "required_count")) && - (Reflect.get(barrier, "launch_at") === null || - (typeof Reflect.get(barrier, "launch_at") === "string" && - !Number.isNaN(Date.parse(String(Reflect.get(barrier, "launch_at")))))) && - ["direct_lan", "tcp_tunnel"].includes(String(Reflect.get(payload, "route"))) - ); -} - function roomIdFromPayload(payload: unknown): string | undefined { if (typeof payload !== "object" || payload === null || !("room_id" in payload)) return undefined; const roomId = Reflect.get(payload, "room_id"); diff --git a/apps/client/src/lib/api.test.ts b/apps/client/src/lib/api.test.ts index 5a15a2f..93959af 100644 --- a/apps/client/src/lib/api.test.ts +++ b/apps/client/src/lib/api.test.ts @@ -42,6 +42,26 @@ describe("runtime API configuration", () => { await expect(api.games("session-token")).rejects.toMatchObject({ code: "invalid_response" }); }); + it("rejects a launch snapshot with an incomplete payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + type: "room.snapshot", + version: "1.0", + request_id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + payload: { room: {}, route: "direct_lan" }, + }), + }) + ); + await expect(api.roomSnapshot("session-token", "room-1")).rejects.toMatchObject({ + code: "invalid_response", + }); + }); + it("rejects non-http origins and embedded credentials", () => { expect(() => configureApiBase("file:///tmp/server")).toThrow(/HTTP or HTTPS/); expect(() => configureApiBase("https://user:pass@example.com")).toThrow(/credentials/); diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index 839de68..3dfef8d 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -3,12 +3,14 @@ import type { AlphaFailureReport, Envelope, MatchPreflightPayload, + MatchReceiptPayload, MatchReport, ProductEventPayload, RoomInvitePayload, RoomPayload, RoomSnapshotPayload, } from "@opencade/protocol"; +import { isRoomSnapshot } from "./snapshot"; export type User = { id: string; username: string; email?: string | null }; export type AuthPayload = { user: User; token: string; expires_at: string }; @@ -79,7 +81,12 @@ export function getApiBase(): string { return apiBase; } -async function request(path: string, token?: string | null, init?: RequestInit): Promise { +async function request( + path: string, + token?: string | null, + init?: RequestInit, + decoder?: (value: unknown) => value is T +): Promise { const headers = new Headers(init?.headers); headers.set("Accept", "application/json"); if (init?.body) headers.set("Content-Type", "application/json"); @@ -104,6 +111,13 @@ async function request(path: string, token?: string | null, init?: RequestIni if (!envelope || envelope.payload === undefined) { throw new ApiError(response.status, "invalid_response", "Server returned an invalid envelope"); } + if (decoder && !decoder(envelope.payload)) { + throw new ApiError( + response.status, + "invalid_response", + "Server returned an invalid response payload" + ); + } return envelope.payload as T; } @@ -202,17 +216,33 @@ export const api = { joinInvite: (token: string, code: string) => request("/api/v1/invites/join", token, post({ code })), submitPreflight: (token: string, roomId: string, payload: MatchPreflightPayload) => - request(`/api/v1/rooms/${roomId}/preflight`, token, post(payload)), + request( + `/api/v1/rooms/${roomId}/preflight`, + token, + post(payload), + isRoomSnapshot + ), roomSnapshot: (token: string, roomId: string) => - request(`/api/v1/rooms/${roomId}/snapshot`, token), + request( + `/api/v1/rooms/${roomId}/snapshot`, + token, + undefined, + isRoomSnapshot + ), readyToLaunch: (token: string, roomId: string) => - request(`/api/v1/rooms/${roomId}/ready`, token, post()), + request(`/api/v1/rooms/${roomId}/ready`, token, post(), isRoomSnapshot), submitEvidence: (token: string, evidence: MatchReport | AlphaFailureReport) => request<{ digest: string; duplicate: boolean }>( "/api/v1/alpha/evidence", token, post({ evidence }) ), + createMatchReceipt: (token: string, roomId: string, idempotencyKey: string) => + request( + `/api/v1/rooms/${roomId}/receipt`, + token, + post({ idempotency_key: idempotencyKey }) + ), recordProductEvent: (token: string, event: ProductEventPayload) => request<{ accepted: boolean; duplicate: boolean }>( "/api/v1/telemetry/events", diff --git a/apps/client/src/lib/controller.test.ts b/apps/client/src/lib/controller.test.ts new file mode 100644 index 0000000..a30a084 --- /dev/null +++ b/apps/client/src/lib/controller.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { controllerPreflight } from "./controller"; + +describe("controllerPreflight", () => { + it("assigns deterministic complementary player slots", () => { + Object.defineProperty(navigator, "getGamepads", { + configurable: true, + value: vi.fn(() => []), + }); + expect(controllerPreflight("host")).toEqual({ connected: false, player_slot: 1 }); + expect(controllerPreflight("guest")).toEqual({ connected: false, player_slot: 2 }); + }); + + it("requires at least one connected controller", () => { + Object.defineProperty(navigator, "getGamepads", { + configurable: true, + value: vi.fn(() => [{ connected: true } as Gamepad, null, null, null]), + }); + expect(controllerPreflight("host").connected).toBe(true); + }); +}); diff --git a/apps/client/src/lib/controller.ts b/apps/client/src/lib/controller.ts new file mode 100644 index 0000000..9c97d3c --- /dev/null +++ b/apps/client/src/lib/controller.ts @@ -0,0 +1,9 @@ +import type { ControllerPreflightPayload, MatchReportRole } from "@opencade/protocol"; + +export function controllerPreflight(role: MatchReportRole): ControllerPreflightPayload { + const pads = typeof navigator.getGamepads === "function" ? navigator.getGamepads() : []; + return { + connected: Array.from(pads).some((pad) => pad?.connected), + player_slot: role === "host" ? 1 : 2, + }; +} diff --git a/apps/client/src/lib/native.ts b/apps/client/src/lib/native.ts index 9f6a0e6..8d13a24 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -29,7 +29,7 @@ export type MatchProbeReport = { }; export type RetroarchMatchLaunch = { pid: number; - adapter: "retroarch_fbneo"; + adapter: "retroarch_fbneo" | "retroarch_test"; room_id: string; fingerprint: { retroarch_version?: string | null; @@ -37,9 +37,10 @@ export type RetroarchMatchLaunch = { core_sha256: string; content_sha256: string; }; + native_route?: "direct_lan" | "tcp_tunnel"; }; export type RetroarchPreflight = { - adapter: "retroarch_fbneo"; + adapter: "retroarch_fbneo" | "retroarch_test"; emulator_version?: string | null; executable_sha256: string; core_sha256: string; diff --git a/apps/client/src/lib/report.test.ts b/apps/client/src/lib/report.test.ts index 2ae0845..b91601f 100644 --- a/apps/client/src/lib/report.test.ts +++ b/apps/client/src/lib/report.test.ts @@ -111,6 +111,31 @@ describe("buildMatchReport", () => { expect(JSON.stringify(report.compatibility)).not.toContain("C:\\"); expect(JSON.stringify(report.compatibility)).not.toContain("path"); }); + + it("records an evidence-qualified native tunnel separately from probe transport", () => { + const report = buildMatchReport( + { + id: "room-123", + game_id: "opencade_test", + host_id: "host", + guest_id: "guest", + state: "finished", + }, + probe, + new Date("2026-08-23T12:00:00Z"), + { + adapter: "retroarch_test", + emulator_version: "1.22.0", + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + }, + "tcp_tunnel" + ); + + expect(report.probe.transport).toBe("direct_udp"); + expect(report.native_route).toBe("tcp_tunnel"); + }); }); describe("buildAlphaFailureReport", () => { @@ -130,6 +155,7 @@ describe("buildAlphaFailureReport", () => { stage: "relay", error_code: "relay_probe_failed", transport: "relay", + native_route: "tcp_tunnel", }, new Date("2026-08-24T12:00:00Z") ); @@ -138,6 +164,7 @@ describe("buildAlphaFailureReport", () => { expect(report.kind).toBe("attempt_failure"); expect(report.stage).toBe("relay"); expect(report.transport).toBe("relay"); + expect(report.native_route).toBe("tcp_tunnel"); expect(serialized).not.toContain("host_id"); expect(serialized).not.toContain("guest_id"); expect(serialized).not.toContain("endpoint"); diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index 7b44d54..d579aa9 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -13,7 +13,8 @@ export function buildMatchReport( room: RoomPayload, probe: MatchProbeReport, now = new Date(), - compatibility?: MatchReportCompatibility + compatibility?: MatchReportCompatibility, + nativeRoute: "direct_lan" | "tcp_tunnel" = "direct_lan" ): MatchReport { return { schema_version: 1, @@ -35,6 +36,7 @@ export function buildMatchReport( user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, }, compatibility: compatibility ?? null, + native_route: nativeRoute, }; } @@ -46,7 +48,7 @@ export function downloadMatchReport( const compatibility = compatibilityFromLaunch(playable); downloadJson( `opencade-match-${room.id.slice(0, 8)}.json`, - buildMatchReport(room, probe, new Date(), compatibility) + buildMatchReport(room, probe, new Date(), compatibility, playable?.native_route) ); } @@ -68,6 +70,7 @@ export type AlphaFailureEvidence = { stage: AlphaFailureStage; error_code: string; transport?: MatchReportTransport; + native_route?: "direct_lan" | "tcp_tunnel"; }; export function buildAlphaFailureReport( @@ -88,6 +91,7 @@ export function buildAlphaFailureReport( stage: failure.stage, error_code: failure.error_code, transport: failure.transport ?? null, + native_route: failure.native_route ?? null, client: { platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, diff --git a/apps/client/src/lib/snapshot.test.ts b/apps/client/src/lib/snapshot.test.ts index 79ca963..4cc081f 100644 --- a/apps/client/src/lib/snapshot.test.ts +++ b/apps/client/src/lib/snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { RoomSnapshotPayload } from "@opencade/protocol"; -import { newestSnapshot } from "./snapshot"; +import { isRoomSnapshot, newestSnapshot } from "./snapshot"; const snapshot = (revision: number): RoomSnapshotPayload => ({ room: { @@ -13,6 +13,7 @@ const snapshot = (revision: number): RoomSnapshotPayload => ({ revision, preflight_count: 2, compatibility_matched: true, + controller_ready_count: 2, barrier: { room_id: "room-1", ready_count: 2, @@ -20,6 +21,12 @@ const snapshot = (revision: number): RoomSnapshotPayload => ({ launch_at: "2026-08-25T12:00:00Z", }, route: "direct_lan", + route_policy: { + route: "direct_lan", + reason: "tcp_tunnel_operator_disabled", + evidence_attempts: 0, + evidence_verified: 0, + }, }); describe("newestSnapshot", () => { @@ -28,4 +35,22 @@ describe("newestSnapshot", () => { expect(newestSnapshot(snapshot(8), snapshot(8)).revision).toBe(8); expect(newestSnapshot(snapshot(8), snapshot(9)).revision).toBe(9); }); + + it("validates every launch-critical snapshot field", () => { + expect(isRoomSnapshot(snapshot(8))).toBe(true); + expect(isRoomSnapshot({ ...snapshot(8), controller_ready_count: undefined })).toBe(false); + expect(isRoomSnapshot({ ...snapshot(8), route_policy: undefined })).toBe(false); + expect( + isRoomSnapshot({ + ...snapshot(8), + route_policy: { ...snapshot(8).route_policy, route: "tcp_tunnel" }, + }) + ).toBe(false); + expect( + isRoomSnapshot({ + ...snapshot(8), + barrier: { ...snapshot(8).barrier, room_id: "another-room" }, + }) + ).toBe(false); + }); }); diff --git a/apps/client/src/lib/snapshot.ts b/apps/client/src/lib/snapshot.ts index 1a880f0..4a796a1 100644 --- a/apps/client/src/lib/snapshot.ts +++ b/apps/client/src/lib/snapshot.ts @@ -1,4 +1,69 @@ -import type { RoomSnapshotPayload } from "@opencade/protocol"; +import type { RoomPayload, RoomSnapshotPayload } from "@opencade/protocol"; + +const ROOM_STATES = new Set([ + "waiting", + "ready", + "challenging", + "connecting", + "playing", + "finished", + "cancelled", +]); +const ROUTES = new Set(["direct_lan", "tcp_tunnel"]); + +function isBoundedCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 255; +} + +function isEvidenceCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 4_294_967_295; +} + +export function isRoomPayload(payload: unknown): payload is RoomPayload { + return ( + typeof payload === "object" && + payload !== null && + typeof Reflect.get(payload, "id") === "string" && + typeof Reflect.get(payload, "game_id") === "string" && + typeof Reflect.get(payload, "host_id") === "string" && + (Reflect.get(payload, "guest_id") === null || + typeof Reflect.get(payload, "guest_id") === "string") && + ROOM_STATES.has(String(Reflect.get(payload, "state"))) + ); +} + +export function isRoomSnapshot(payload: unknown): payload is RoomSnapshotPayload { + if (typeof payload !== "object" || payload === null) return false; + const room = Reflect.get(payload, "room"); + const barrier = Reflect.get(payload, "barrier"); + const policy = Reflect.get(payload, "route_policy"); + const route = Reflect.get(payload, "route"); + return ( + isRoomPayload(room) && + Number.isSafeInteger(Reflect.get(payload, "revision")) && + Number(Reflect.get(payload, "revision")) >= 0 && + isBoundedCount(Reflect.get(payload, "preflight_count")) && + typeof Reflect.get(payload, "compatibility_matched") === "boolean" && + isBoundedCount(Reflect.get(payload, "controller_ready_count")) && + typeof barrier === "object" && + barrier !== null && + Reflect.get(barrier, "room_id") === room.id && + isBoundedCount(Reflect.get(barrier, "ready_count")) && + isBoundedCount(Reflect.get(barrier, "required_count")) && + (Reflect.get(barrier, "launch_at") === null || + (typeof Reflect.get(barrier, "launch_at") === "string" && + !Number.isNaN(Date.parse(String(Reflect.get(barrier, "launch_at")))))) && + ROUTES.has(String(route)) && + typeof policy === "object" && + policy !== null && + Reflect.get(policy, "route") === route && + typeof Reflect.get(policy, "reason") === "string" && + isEvidenceCount(Reflect.get(policy, "evidence_attempts")) && + isEvidenceCount(Reflect.get(policy, "evidence_verified")) && + Number(Reflect.get(policy, "evidence_verified")) <= + Number(Reflect.get(policy, "evidence_attempts")) + ); +} export function newestSnapshot( current: RoomSnapshotPayload | undefined, diff --git a/apps/client/src/lib/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts index 7e50ae5..22df5c4 100644 --- a/apps/client/src/lib/usePlayableMatch.ts +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -7,13 +7,16 @@ import type { } from "@opencade/protocol"; import { api } from "./api"; import { matchParticipants, selectNativeRoute } from "./match"; +import { controllerPreflight } from "./controller"; import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; import { trackProductEvent } from "./telemetry"; import { launchRetroarchMatch, onEmulatorExit, retroarchPreflight, + startNativeTcpTunnel, stopGame, + stopNativeTcpTunnel, type MatchEndpointCandidate, type MatchProbeReport, } from "./native"; @@ -56,6 +59,7 @@ export function usePlayableMatch({ const preflight = useMutation({ mutationFn: async () => { if (!room) throw new Error("Room is unavailable for compatibility preflight"); + if (!participants) throw new Error("Two room participants are required for preflight"); const local = await retroarchPreflight(room.game_id); return api.submitPreflight(token, roomId, { room_id: roomId, @@ -67,6 +71,7 @@ export function usePlayableMatch({ core_sha256: local.core_sha256, content_sha256: local.content_sha256, }, + controller: controllerPreflight(participants.role), }); }, onSuccess: (value) => queryClient.setQueryData(["room-snapshot", roomId], value), @@ -103,29 +108,50 @@ export function usePlayableMatch({ throw new Error("Native gameplay requires a verified direct UDP path"); } if (!canLaunch) throw new Error("Synchronized launch barrier is not ready"); - const route = selectNativeRoute( - room, - userId, - localEndpoint.endpoint, - peerEndpoint.endpoint, - probeReport.transport, - probeReport.candidate - ); + const nativeRoute = snapshot.data?.route ?? "direct_lan"; + const route = + nativeRoute === "tcp_tunnel" + ? { local: "127.0.0.1:55435", peer: "127.0.0.1:55435" } + : selectNativeRoute( + room, + userId, + localEndpoint.endpoint, + peerEndpoint.endpoint, + probeReport.transport, + probeReport.candidate + ); dispatch({ type: "launch_requested" }); await trackProductEvent(token, "launch_attempted", room.game_id).catch(() => false); - const grant = await api.createLaunchGrant(token, roomId, route.local, route.peer); - const launch = await launchRetroarchMatch({ - launch_grant: grant.grant, - }); + if (nativeRoute === "tcp_tunnel") { + const relay = await api.nativeTunnelTicket(token, roomId); + if (relay.ticket.capability !== "native_tcp_tunnel") { + throw new Error("Server returned an invalid native tunnel capability"); + } + await startNativeTcpTunnel({ + relay_url: relay.relay_url, + ticket: { ...relay.ticket, capability: "native_tcp_tunnel" }, + mode: participants.role === "host" ? "connect" : "listen", + local_endpoint: "127.0.0.1:55435", + }); + } + let launch; + try { + const grant = await api.createLaunchGrant(token, roomId, route.local, route.peer); + launch = await launchRetroarchMatch({ launch_grant: grant.grant }); + } catch (error) { + if (nativeRoute === "tcp_tunnel") await stopNativeTcpTunnel(roomId).catch(() => undefined); + throw error; + } try { await api.startRoom(token, roomId); } catch (error) { await stopGame(launch.pid).catch(() => undefined); + if (nativeRoute === "tcp_tunnel") await stopNativeTcpTunnel(roomId).catch(() => undefined); throw error; } await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); await trackProductEvent(token, "launch_succeeded", room.game_id).catch(() => false); - return launch; + return { ...launch, native_route: nativeRoute }; }, onSuccess: () => dispatch({ type: "native_spawned" }), onError: (error) => @@ -163,10 +189,15 @@ export function usePlayableMatch({ }, [coordinator.phase, preflight]); useEffect(() => { - if (!snapshot.data?.compatibility_matched || readyStarted.current) return; + if ( + !snapshot.data?.compatibility_matched || + snapshot.data.controller_ready_count !== 2 || + readyStarted.current + ) + return; readyStarted.current = true; ready.mutate(); - }, [ready, snapshot.data?.compatibility_matched]); + }, [ready, snapshot.data?.compatibility_matched, snapshot.data?.controller_ready_count]); useEffect(() => { if (room?.state === "playing") dispatch({ type: "room_playing" }); @@ -190,6 +221,13 @@ export function usePlayableMatch({ }; }, [queryClient, roomId]); + useEffect( + () => () => { + void stopNativeTcpTunnel(roomId); + }, + [roomId] + ); + return { coordinator, participants, @@ -198,6 +236,12 @@ export function usePlayableMatch({ preflightPending: preflight.isPending, launchBarrierPending: ready.isPending, canLaunch, + retryPreflight: () => { + preflightStarted.current = true; + readyStarted.current = false; + preflight.reset(); + preflight.mutate(); + }, resetCoordinator: () => { preflightStarted.current = false; readyStarted.current = false; diff --git a/apps/client/src/lib/ws.test.ts b/apps/client/src/lib/ws.test.ts index d8fb866..82e6b9e 100644 --- a/apps/client/src/lib/ws.test.ts +++ b/apps/client/src/lib/ws.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { reconnectDelay } from "./ws.js"; describe("reconnectDelay", () => { - it("backs off exponentially and caps at 30 seconds", () => { - expect(reconnectDelay(0)).toBe(500); - expect(reconnectDelay(3)).toBe(4_000); - expect(reconnectDelay(20)).toBe(30_000); + it("uses bounded jitter while backing off exponentially", () => { + expect(reconnectDelay(0, () => 0)).toBe(250); + expect(reconnectDelay(0, () => 1)).toBe(500); + expect(reconnectDelay(3, () => 0)).toBe(2_000); + expect(reconnectDelay(3, () => 1)).toBe(4_000); + expect(reconnectDelay(20, () => 0)).toBe(15_000); + expect(reconnectDelay(20, () => 1)).toBe(30_000); }); }); diff --git a/apps/client/src/lib/ws.ts b/apps/client/src/lib/ws.ts index 50c544c..74cc3c6 100644 --- a/apps/client/src/lib/ws.ts +++ b/apps/client/src/lib/ws.ts @@ -3,8 +3,10 @@ import { createEnvelope, parseEnvelope, type Envelope } from "@opencade/protocol export type ConnectionState = "idle" | "connecting" | "open" | "reconnecting" | "closed"; export type MessageListener = (message: Envelope) => void; -export function reconnectDelay(attempt: number): number { - return Math.min(30_000, 500 * 2 ** Math.max(0, attempt)); +export function reconnectDelay(attempt: number, random: () => number = Math.random): number { + const cap = Math.min(30_000, 500 * 2 ** Math.max(0, attempt)); + const unit = Math.max(0, Math.min(1, random())); + return Math.round(cap / 2 + (cap / 2) * unit); } export class OpenCadeSocket { diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index b282640..11b0eb0 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { MatchEndpointPayload, MatchProbeCompletedPayload } from "@opencade/protocol"; import { api } from "../lib/api"; import { @@ -57,6 +57,7 @@ export default function Match({ preflightPending, launchBarrierPending, canLaunch, + retryPreflight, resetCoordinator, } = usePlayableMatch({ token, @@ -71,13 +72,18 @@ export default function Match({ const [now, setNow] = useState(Date.now()); const [isLeaving, setIsLeaving] = useState(false); const [leaveError, setLeaveError] = useState(""); + const [receiptCopied, setReceiptCopied] = useState(false); + const [receiptCopyError, setReceiptCopyError] = useState(""); + const receiptKey = useRef(crypto.randomUUID()); const uploadedSuccess = useRef(false); const uploadedFailure = useRef(false); useEffect(() => { - if (!snapshot?.barrier.launch_at) return; + if (!snapshot?.barrier.launch_at || state !== "connecting") return; + const deadline = new Date(snapshot.barrier.launch_at).getTime(); + if (deadline <= Date.now()) return; const timer = window.setInterval(() => setNow(Date.now()), 500); return () => window.clearInterval(timer); - }, [snapshot?.barrier.launch_at]); + }, [snapshot?.barrier.launch_at, state]); const steps = ["connecting", "playing", "finished"]; const active = Math.max(0, steps.indexOf(state)); const heading = @@ -97,6 +103,7 @@ export default function Match({ stage: "native_launch" as const, error_code: "native_launch_failed", transport: probeReport?.transport, + native_route: snapshot?.route, } : coordinator.phase === "failed" && coordinator.error?.includes("transcript") ? { @@ -105,19 +112,39 @@ export default function Match({ transport: probeReport?.transport, } : probeFailure; - useEffect(() => { - if (!completedRoom || !probeReport || !playableMatch.data || uploadedSuccess.current) return; - uploadedSuccess.current = true; - const report = buildMatchReport( - completedRoom, - probeReport, - new Date(), - compatibilityFromLaunch(playableMatch.data) - ); - void api.submitEvidence(token, report).catch(() => { + const evidenceUpload = useMutation({ + mutationFn: async () => { + if (!completedRoom || !probeReport || !playableMatch.data) { + throw new Error("Completed match evidence is unavailable"); + } + const report = buildMatchReport( + completedRoom, + probeReport, + new Date(), + compatibilityFromLaunch(playableMatch.data), + playableMatch.data.native_route + ); + return api.submitEvidence(token, report); + }, + retry: 2, + onMutate: () => { + uploadedSuccess.current = true; + }, + onError: () => { uploadedSuccess.current = false; - }); - }, [completedRoom, playableMatch.data, probeReport, token]); + }, + }); + useEffect(() => { + if ( + !completedRoom || + !probeReport || + !playableMatch.data || + uploadedSuccess.current || + evidenceUpload.status !== "idle" + ) + return; + evidenceUpload.mutate(); + }, [completedRoom, evidenceUpload, playableMatch.data, probeReport]); useEffect(() => { if (!failureRoom || !participants || !failureEvidence || uploadedFailure.current) return; uploadedFailure.current = true; @@ -129,6 +156,17 @@ export default function Match({ const launchSeconds = snapshot?.barrier.launch_at ? Math.max(0, Math.ceil((new Date(snapshot.barrier.launch_at).getTime() - now) / 1000)) : undefined; + const receipt = useMutation({ + mutationFn: () => api.createMatchReceipt(token, roomId, receiptKey.current), + }); + const receiptText = receipt.data + ? [ + `OpenCade ${receipt.data.game_id} match: ${receipt.data.result}`, + `Route: ${receipt.data.route}`, + `Next-match invite: ${receipt.data.invite.code}`, + `Expires: ${new Date(receipt.data.invite.expires_at).toLocaleString()}`, + ].join("\n") + : ""; return (

Room {roomId.slice(0, 8)}

@@ -140,7 +178,11 @@ export default function Match({
    {steps.map((step, index) => ( -
  1. +
  2. {step}
  3. ))} @@ -163,11 +205,13 @@ export default function Match({ ? "Checking RetroArch, core, content, and TCP port…" : !snapshot?.compatibility_matched ? `Waiting for matching peer preflight (${snapshot?.preflight_count ?? 0}/2)` - : launchBarrierPending || snapshot.barrier.ready_count < 2 - ? `Compatibility matched · launch ready ${snapshot.barrier.ready_count}/2` - : launchSeconds && launchSeconds > 0 - ? `Synchronized launch opens in ${launchSeconds}s` - : "Compatibility and synchronized launch barrier verified"} + : snapshot.controller_ready_count < 2 + ? `Connect a controller · ready ${snapshot.controller_ready_count}/2` + : launchBarrierPending || snapshot.barrier.ready_count < 2 + ? `Compatibility matched · launch ready ${snapshot.barrier.ready_count}/2` + : launchSeconds && launchSeconds > 0 + ? `Synchronized launch opens in ${launchSeconds}s` + : "Compatibility and synchronized launch barrier verified"}

    )} {coordinator.phase === "relay_probe_only" && ( @@ -207,6 +251,42 @@ export default function Match({ {playableMatch.data.fingerprint.content_sha256.slice(0, 12)}

    )} + {receipt.data && ( +
    + + Verified match receipt created. + +

    Verified match receipt

    +

    Invite the next opponent

    +

    + {receipt.data.game_id} · {receipt.data.route.replaceAll("_", " ")} · compatibility + verified +

    + {receipt.data.invite.code} + Expires {new Date(receipt.data.invite.expires_at).toLocaleString()} +
    + )} + {receipt.isError && ( + + )} + {evidenceUpload.isError && ( + + )} + {receiptCopied && ( +

    + Match receipt copied. +

    + )} + {receiptCopyError && ( +

    + {receiptCopyError} +

    + )}
    {(probeError || coordinator.phase === "relay_probe_only") && ( + )} {completedRoom && probeReport && ( + )} + {evidenceUpload.isError && ( + + )} + {receipt.data && ( + + )} {failureRoom && participants && failureEvidence && ( +
    +

    Loading compatibility evidence…

    +
    + + + + + + + + + + + +
    GamePlatformAdapterRouteVerified rate and sample
    +
    +
+ + + + diff --git a/site/compatibility/main.js b/site/compatibility/main.js new file mode 100644 index 0000000..73890d1 --- /dev/null +++ b/site/compatibility/main.js @@ -0,0 +1,67 @@ +const status = document.querySelector("#status"); +const cohorts = document.querySelector("#cohorts"); +const refresh = document.querySelector("#refresh"); + +function apiOrigin() { + const requested = new URLSearchParams(window.location.search).get("api"); + const candidate = requested || window.location.origin; + const parsed = new URL(candidate); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) { + throw new Error("The compatibility API must use HTTPS unless it runs on this machine."); + } + return parsed.origin; +} + +function cell(row, value) { + const element = document.createElement("td"); + element.textContent = value; + row.append(element); +} + +function render(items) { + cohorts.replaceChildren(); + const ranked = [...items].sort((left, right) => { + const leftRate = left.attempts > 0 ? left.verified / left.attempts : 0; + const rightRate = right.attempts > 0 ? right.verified / right.attempts : 0; + return rightRate - leftRate || right.attempts - left.attempts; + }); + for (const item of ranked) { + const row = document.createElement("tr"); + cell(row, item.game_id); + cell(row, item.platform); + cell(row, `${item.adapter}${item.emulator_version ? ` ${item.emulator_version}` : ""}`); + cell(row, `${item.native_route.replaceAll("_", " ")} · ${item.transport.replaceAll("_", " ")}`); + const rate = item.attempts > 0 ? Math.round((item.verified / item.attempts) * 100) : 0; + cell(row, `${rate}% · ${item.verified}/${item.attempts} rooms`); + cohorts.append(row); + } +} + +async function load() { + refresh.setAttribute("aria-busy", "true"); + status.textContent = "Loading compatibility evidence…"; + try { + const response = await fetch(`${apiOrigin()}/api/v1/public/compatibility`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error(`Evidence service returned HTTP ${response.status}.`); + const envelope = await response.json(); + const items = Array.isArray(envelope?.payload?.cohorts) ? envelope.payload.cohorts : []; + render(items); + const threshold = Number(envelope?.payload?.minimum_cohort_size) || 3; + status.textContent = items.length + ? `${items.length} privacy-safe cohorts · minimum ${threshold} rooms · generated ${new Date(envelope.payload.generated_at).toLocaleString()}` + : `No cohort has reached the ${threshold}-room publication threshold yet.`; + } catch (error) { + status.textContent = + error instanceof Error + ? `${error.message} Add ?api=https://your-server.example to select the public API.` + : "Compatibility evidence is unavailable. Try again."; + } finally { + refresh.removeAttribute("aria-busy"); + } +} + +refresh.addEventListener("click", () => void load()); +void load(); diff --git a/site/compatibility/styles.css b/site/compatibility/styles.css new file mode 100644 index 0000000..0859dc0 --- /dev/null +++ b/site/compatibility/styles.css @@ -0,0 +1,127 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: hsl(220 15% 10%); + color: hsl(220 10% 94%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: hsl(220 15% 10%); +} + +main { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0 96px; +} + +header { + max-width: 720px; + margin-bottom: 48px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 16px; + font-size: clamp(32px, 6vw, 64px); + line-height: 1.1; +} + +h2 { + margin-bottom: 0; + font-size: 24px; +} + +.eyebrow { + margin-bottom: 8px; + color: hsl(212 92% 68%); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lede, +#status { + color: hsl(220 8% 70%); + line-height: 1.6; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +button { + min-height: 48px; + padding: 0 16px; + border: 1px solid hsl(212 80% 58%); + border-radius: 8px; + background: hsl(212 80% 52%); + color: white; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: hsl(212 80% 46%); +} + +button:active { + background: hsl(212 80% 42%); +} + +button:focus-visible, +.table-scroll:focus-visible { + outline: 3px solid hsl(48 100% 60%); + outline-offset: 3px; +} + +.table-scroll { + overflow-x: auto; + border-radius: 8px; + background: hsl(220 13% 14%); +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +th, +td { + padding: 16px; + border-bottom: 1px solid hsl(220 10% 24%); +} + +th { + color: hsl(220 8% 70%); + font-size: 13px; + font-weight: 600; +} + +@media (max-width: 768px) { + main { + padding-top: 32px; + } + + .section-heading { + align-items: stretch; + flex-direction: column; + } +}