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) => (
- -
+
-
{step}
))}
@@ -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 && (
+
+ Both players’ verified reports must arrive before the next-match receipt can be created.
+ Export or upload the missing report, then retry.
+
+ )}
+ {evidenceUpload.isError && (
+
+ Match evidence could not be uploaded. Retry the upload before creating an invite.
+
+ )}
+ {receiptCopied && (
+
+ Match receipt copied.
+
+ )}
+ {receiptCopyError && (
+
+ {receiptCopyError}
+
+ )}
{(probeError || coordinator.phase === "relay_probe_only") && (