Add web game (plan2.md) on the independent runtime, merge-blocking gates

Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:20:46 -07:00
co-authored by Claude Opus 4.8
parent 659544f0b2
commit 9d9d5ce41c
33 changed files with 5065 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "web_tests"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
protocol = { path = "../protocol" }
game_runtime = { path = "../game_runtime" }
world_model = { path = "../world_model" }
rune_ir = { path = "../rune_ir" }
server = { path = "../server" }
+12
View File
@@ -0,0 +1,12 @@
{
"name": "magicka-web-e2e",
"version": "0.1.0",
"private": true,
"description": "Playwright rendered-browser E2E for the Magicka VM web game.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.40.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Playwright config for the rendered-browser E2E layer. This complements the
// headless Rust protocol E2E in `crates/web_tests/tests/e2e.rs`: here a real
// Chromium drives the actual DOM client. It boots the real server (short turns)
// via `webServer` so `npm test` is self-contained.
//
// Requires Node + `npx playwright install chromium`. The Rust CI gates do not
// depend on this; it is the optional rendered-browser proof.
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./specs",
timeout: 30000,
expect: { timeout: 10000 },
use: {
baseURL: "http://127.0.0.1:8099",
headless: true,
},
webServer: {
// Build once, then run the server with fast turns for E2E.
command: "cargo run --release -p server --bin magicka-server",
cwd: "../../..",
env: { MAGICKA_ADDR: "127.0.0.1:8099", MAGICKA_TURN_MS: "1500" },
url: "http://127.0.0.1:8099/",
reuseExistingServer: true,
timeout: 120000,
},
});
+43
View File
@@ -0,0 +1,43 @@
// Rendered-browser E2E: a real Chromium joins a match, the turn timer runs, the
// player edits + saves a rune program, casts, sees filtered results, and replays
// the match — verifying the recorded replay hashes match what was seen live.
//
// This is the browser layer of plan2.md Phase H ("Playwright end-to-end tests").
const { test, expect } = require("@playwright/test");
test("a player can join, cast, and replay a browser match", async ({ page }) => {
await page.goto("/");
// Connects to the authoritative server.
await expect(page.locator("#conn")).toHaveText("connected", { timeout: 10000 });
// The turn timer is running (header shows a turn + countdown).
await expect(page.locator("#timer")).toContainText("turn", { timeout: 10000 });
// The arena rendered with the player's marker.
await expect(page.locator(".cell.self")).toHaveCount(1, { timeout: 10000 });
// Domains panel shows the hidden-state redaction notice (visibility layer).
await expect(page.locator("#redactions")).toContainText("withheld", { timeout: 10000 });
// Edit a rune program: add a couple of runes and save.
await page.fill("#tok-a", "1");
await page.fill("#tok-b", "2");
await page.click("#btn-add");
await page.click("#btn-add");
await page.click("#btn-save");
// Observed diagnostics appear (names/counts only).
await expect(page.locator("#diag-body")).toContainText("known reads", { timeout: 10000 });
// Cast and wait for a resolved-turn log line.
await page.click("#btn-cast");
await expect(page.locator("#log")).toContainText("cast a rune program", { timeout: 15000 });
// Request and step the replay; the client verifies hashes vs. what it saw live.
await page.click("#btn-replay");
await expect(page.locator("#replay-status")).toContainText("replay loaded", { timeout: 10000 });
await page.click("#btn-replay-step");
// A matching hash line is shown (no MISMATCH).
await expect(page.locator(".hash-bad")).toHaveCount(0);
});
+170
View File
@@ -0,0 +1,170 @@
//! `web_tests` — a tiny, dependency-free WebSocket *client* used to drive the
//! real server over a real socket in integration tests. It performs the HTTP
//! upgrade, masks client frames (as RFC 6455 requires), and reads server
//! frames. This is the harness behind the Phase H web CI gates.
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use protocol::{ClientMessage, ServerMessage};
/// A blocking WebSocket client connection to the test server.
pub struct WsClient {
stream: TcpStream,
reader: BufReader<TcpStream>,
}
impl WsClient {
/// Connect, upgrade to WebSocket, and verify the handshake.
pub fn connect(addr: &str) -> io::Result<WsClient> {
let stream = TcpStream::connect(addr)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_nodelay(true).ok();
let mut reader = BufReader::new(stream.try_clone()?);
// A fixed client key keeps the handshake assertion deterministic.
let key = "dGhlIHNhbXBsZSBub25jZQ==";
let mut s = stream.try_clone()?;
let req = format!(
"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n\
Sec-WebSocket-Version: 13\r\n\r\n"
);
s.write_all(req.as_bytes())?;
s.flush()?;
// Read the response head.
let mut status = String::new();
reader.read_line(&mut status)?;
if !status.contains("101") {
return Err(io::Error::new(io::ErrorKind::Other, format!("no upgrade: {status:?}")));
}
let mut saw_accept = false;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 || line.trim_end().is_empty() {
break;
}
if line.to_ascii_lowercase().starts_with("sec-websocket-accept:") {
let got = line.split(':').nth(1).unwrap_or("").trim();
// Expected accept for the canonical key above.
if got == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" {
saw_accept = true;
}
}
}
if !saw_accept {
return Err(io::Error::new(io::ErrorKind::Other, "bad Sec-WebSocket-Accept"));
}
Ok(WsClient { stream, reader })
}
/// Send a typed client message.
pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> {
self.send_raw_text(&msg.encode())
}
/// Send arbitrary text as a masked frame (used by fuzz tests).
pub fn send_raw_text(&mut self, text: &str) -> io::Result<()> {
self.write_masked(0x1, text.as_bytes())
}
/// Send arbitrary bytes as a masked binary frame (fuzz transport).
pub fn send_raw_bytes(&mut self, bytes: &[u8]) -> io::Result<()> {
self.write_masked(0x2, bytes)
}
fn write_masked(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> {
let mask = [0x12u8, 0x34, 0x56, 0x78];
let mut frame = vec![0x80 | opcode];
let len = payload.len();
if len < 126 {
frame.push(0x80 | len as u8);
} else if len < 65536 {
frame.push(0x80 | 126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(0x80 | 127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
self.stream.write_all(&frame)?;
self.stream.flush()
}
/// Read one server text frame and decode it. Skips control frames.
pub fn recv(&mut self) -> io::Result<ServerMessage> {
let text = self.recv_text()?;
ServerMessage::decode(&text)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}")))
}
/// Read one server text frame (raw).
pub fn recv_text(&mut self) -> io::Result<String> {
loop {
let mut hdr = [0u8; 2];
self.reader.read_exact(&mut hdr)?;
let opcode = hdr[0] & 0x0f;
let masked = hdr[1] & 0x80 != 0;
let len7 = (hdr[1] & 0x7f) as usize;
let len = match len7 {
126 => {
let mut b = [0u8; 2];
self.reader.read_exact(&mut b)?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
self.reader.read_exact(&mut b)?;
u64::from_be_bytes(b) as usize
}
n => n,
};
// Server frames are not masked, but tolerate it.
let mask = if masked {
let mut m = [0u8; 4];
self.reader.read_exact(&mut m)?;
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
self.reader.read_exact(&mut payload)?;
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
match opcode {
0x1 | 0x2 => return Ok(String::from_utf8_lossy(&payload).into_owned()),
0x8 => return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "closed")),
_ => continue, // ping/pong/continuation
}
}
}
/// Receive until a predicate matches, returning that message. Bounded so a
/// test never hangs.
pub fn recv_until<F: Fn(&ServerMessage) -> bool>(&mut self, pred: F) -> io::Result<ServerMessage> {
for _ in 0..256 {
let m = self.recv()?;
if pred(&m) {
return Ok(m);
}
}
Err(io::Error::new(io::ErrorKind::Other, "predicate never matched"))
}
}
/// Start a fresh server instance on an ephemeral port for a test, returning the
/// address string. Each call binds a new port so tests are isolated.
pub fn spawn_test_server(turn_ms: u64) -> String {
let addr = server::serve("127.0.0.1:0", server::Config { turn_ms })
.expect("bind test server");
format!("127.0.0.1:{}", addr.port())
}
+64
View File
@@ -0,0 +1,64 @@
//! Phase H gate: replay determinism. The web CI minimum is "1,000 simulated
//! matches" with "0 replay hash mismatches". A match is a pure function of its
//! seed, roster, and ordered inputs, so re-running the recorded inputs must
//! reproduce the final hash exactly.
use game_runtime::{replay, run_scripted, solo_roster, duel_roster};
use protocol::Action;
use world_model::Rng;
/// Build a varied but deterministic script for a given seed.
fn script(seed: u64) -> Vec<Vec<(u32, Action)>> {
let mut rng = Rng::derive(seed, "script");
let mut turns = Vec::new();
for _ in 0..6 {
let mut subs = Vec::new();
for pid in 1..=2u32 {
let a = match rng.below(5) {
0 => Action::Move { dx: rng.range_i64(-1, 1) as i32, dy: rng.range_i64(-1, 1) as i32 },
1 => Action::Cast,
2 => Action::Attack { target: if pid == 1 { 2 } else { 1 } },
3 => Action::Inspect { target: if pid == 1 { 2 } else { 1 } },
_ => Action::Wait,
};
subs.push((pid, a));
}
turns.push(subs);
}
turns
}
#[test]
fn one_thousand_matches_replay_with_zero_drift() {
let mut mismatches = 0u32;
for seed in 0..1000u64 {
let roster = if seed % 2 == 0 {
solo_roster("p")
} else {
duel_roster("a", "b")
};
let scripts = script(seed);
let (m, log) = run_scripted(seed, &roster, &scripts);
let again = replay(seed, &roster, &log.turns);
if m.replay.final_hash != again.final_hash {
mismatches += 1;
}
// Per-turn hashes must also agree.
for (a, b) in m.replay.turns.iter().zip(again.turns.iter()) {
if a.turn_hash != b.turn_hash {
mismatches += 1;
}
}
}
assert_eq!(mismatches, 0, "replay hash mismatches across 1000 matches");
}
#[test]
fn re_executing_same_seed_twice_is_identical() {
for seed in [1u64, 7, 99, 12345, 0xdead_beef] {
let roster = solo_roster("p");
let (a, _) = run_scripted(seed, &roster, &script(seed));
let (b, _) = run_scripted(seed, &roster, &script(seed));
assert_eq!(a.replay.final_hash, b.replay.final_hash, "seed {seed}");
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Phase H gate: end-to-end matches over the real server, real sockets, real
//! protocol. The web CI minimum names "100 browser E2E matches"; a headless
//! browser is not available in this CI, so this drives the full
//! HTTP+WebSocket+protocol+runtime path *headlessly* (it is protocol-level
//! E2E, not a rendered browser — the Playwright harness under `e2e/` covers the
//! rendered browser when Node is present). It asserts: a player can join, the
//! turn timer drives resolution, casts resolve through the runtime, results
//! return as filtered observations, and the recorded replay reproduces the live
//! per-turn hashes (0 replay hash mismatches).
use protocol::{Action, ClientMessage, MatchId, RuneTokenWire, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
struct Played {
match_id: MatchId,
live_hashes: Vec<(u64, String)>,
replay_hashes: Vec<(u64, String)>,
final_hash: String,
saw_filtered_snapshot: bool,
}
/// Play a solo match for `n_turns`, then pull the recorded replay back.
fn play_solo(addr: &str, n_turns: u64, program: &[RuneTokenWire]) -> std::io::Result<Played> {
let mut c = WsClient::connect(addr)?;
c.send(&ClientMessage::JoinMatch { name: "e2e".into(), match_id: None })?;
let (match_id, _player) = match c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. }))? {
ServerMessage::MatchState { match_id, player_id, snapshot, .. } => {
// Filtered snapshot sanity: hidden lanes are redacted.
assert!(snapshot.hidden_state_redactions > 0, "no redactions in snapshot");
(match_id, player_id)
}
_ => unreachable!(),
};
if !program.is_empty() {
c.send(&ClientMessage::EditRuneProgram { tokens: program.to_vec() })?;
}
let mut live_hashes = Vec::new();
let mut saw_filtered = false;
let mut live_turn = 0u64;
for _ in 0..n_turns {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast })?;
let expected = live_turn + 1;
let resolved = c.recv_until(|m| {
matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == expected)
})?;
if let ServerMessage::TurnResolved { turn, runtime_hash, snapshot, .. } = resolved {
// Results come back as filtered observations.
if snapshot.hidden_state_redactions > 0 {
saw_filtered = true;
}
live_hashes.push((turn, runtime_hash));
live_turn = turn;
}
}
// Pull the replay back and collect its per-turn hashes.
c.send(&ClientMessage::RequestReplay { match_id })?;
let mut replay_hashes = Vec::new();
let mut final_hash = String::new();
let mut got = 0u32;
let mut total = 1u32;
while got < total {
match c.recv_until(|m| matches!(m, ServerMessage::ReplayChunk { .. }))? {
ServerMessage::ReplayChunk { total: t, turns, final_hash: fh, .. } => {
total = t.max(1);
got += 1;
final_hash = fh;
for rt in turns {
replay_hashes.push((rt.turn, rt.runtime_hash));
}
}
_ => break,
}
}
Ok(Played { match_id, live_hashes, replay_hashes, final_hash, saw_filtered_snapshot: saw_filtered })
}
#[test]
fn single_match_full_playthrough() {
let addr = spawn_test_server(15);
let program = vec![
RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 5 },
RuneTokenWire { op: 5, a: 2, b: 4, c: 1, imm: -3 },
RuneTokenWire { op: 8, a: 0, b: 6, c: 2, imm: 11 },
];
let played = play_solo(&addr, 4, &program).expect("play");
assert_ne!(played.match_id.0, 0, "a match id was assigned");
assert_eq!(played.live_hashes.len(), 4, "all turns resolved");
assert!(played.saw_filtered_snapshot, "results returned as filtered observations");
assert!(!played.final_hash.is_empty());
// Replay must reproduce every live per-turn hash (0 mismatches).
for (turn, live) in &played.live_hashes {
let found = played.replay_hashes.iter().find(|(t, _)| t == turn);
assert!(found.is_some(), "replay missing turn {turn}");
assert_eq!(&found.unwrap().1, live, "replay hash mismatch at turn {turn}");
}
}
#[test]
fn inspection_returns_observed_diagnostics_only() {
let addr = spawn_test_server(50);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "inspector".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Inspect the dummy (entity id 2).
c.send(&ClientMessage::InspectTarget { target: 2 }).unwrap();
let obs = c.recv_until(|m| matches!(m, ServerMessage::ObservationResult { .. })).unwrap();
if let ServerMessage::ObservationResult { diagnostics, .. } = obs {
// Diagnostics are observed names/counts — never guaranteed truth.
// unknown_listeners is a count; reads/writes are domain names.
for s in diagnostics.known_reads.iter().chain(diagnostics.known_writes.iter()) {
assert!(s.chars().any(|ch| ch.is_alphabetic()));
}
} else {
panic!("expected ObservationResult");
}
}
#[test]
fn one_hundred_e2e_matches_zero_hash_mismatches() {
// Single short-turn server; 100 independent matches over real sockets.
let addr = spawn_test_server(8);
let program = vec![
RuneTokenWire { op: 1, a: 3, b: 1, c: 2, imm: 7 },
RuneTokenWire { op: 6, a: 0, b: 5, c: 3, imm: -9 },
];
let mut mismatches = 0u32;
let mut completed = 0u32;
for _ in 0..100 {
let played = play_solo(&addr, 3, &program).expect("e2e match");
completed += 1;
for (turn, live) in &played.live_hashes {
match played.replay_hashes.iter().find(|(t, _)| t == turn) {
Some((_, rh)) if rh == live => {}
_ => mismatches += 1,
}
}
}
assert_eq!(completed, 100, "all 100 matches completed");
assert_eq!(mismatches, 0, "replay hash mismatches across 100 e2e matches");
}
+84
View File
@@ -0,0 +1,84 @@
//! Phase H gate: protocol fuzzing. The web CI minimum is "10,000 protocol fuzz
//! cases" with "0 server panics". Decoding is total, so every byte string must
//! yield `Ok` or `Err` — never an unwind. A sample is also fired at a live
//! server to prove a malformed packet cannot bring it down.
use protocol::{json, ClientMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::Rng;
/// Generate a pseudo-random byte string from a seed, with a bias toward
/// JSON-ish characters so the parser's deeper paths are exercised.
fn fuzz_bytes(seed: u64) -> Vec<u8> {
let mut rng = Rng::derive(seed, "fuzz");
let alphabet = b"{}[]\":,0123456789tfnuelavabcdef.- \\/\n\t";
let len = rng.below(80);
(0..len)
.map(|_| {
if rng.chance(0.85) {
alphabet[rng.below(alphabet.len())]
} else {
rng.next_u64() as u8
}
})
.collect()
}
#[test]
fn ten_thousand_fuzz_cases_never_panic() {
let mut ok = 0u64;
let mut err = 0u64;
for seed in 0..10_000u64 {
let bytes = fuzz_bytes(seed);
let text = String::from_utf8_lossy(&bytes);
// Raw JSON parse must be total.
let _ = json::parse(&text);
// Full client-message decode must be total.
match ClientMessage::decode(&text) {
Ok(_) => ok += 1,
Err(_) => err += 1,
}
}
// The point is the absence of a panic; both counters are just evidence the
// loop ran to completion.
assert_eq!(ok + err, 10_000);
}
#[test]
fn structured_but_invalid_messages_are_rejected_not_panicked() {
let cases = [
"{}",
"{\"v\":1}",
"{\"v\":2,\"type\":\"Ping\",\"body\":{}}", // wrong version
"{\"v\":1,\"type\":\"Nope\",\"body\":{}}", // unknown type
"{\"v\":1,\"type\":\"SubmitTurn\",\"body\":{}}", // missing fields
"{\"v\":1,\"type\":\"JoinMatch\",\"body\":{\"name\":5}}", // wrong type
];
for c in cases {
assert!(ClientMessage::decode(c).is_err(), "should reject: {c}");
}
}
#[test]
fn live_server_survives_malformed_packets() {
let addr = spawn_test_server(30);
let mut c = WsClient::connect(&addr).expect("connect");
// Fire a burst of garbage frames (kept modest so the join reply is not
// starved behind a flood of rejection reports; decode breadth is covered by
// the 10k case test above).
for seed in 0..40u64 {
let bytes = fuzz_bytes(seed);
let _ = c.send_raw_bytes(&bytes);
}
// Also send raw garbage text.
for s in ["", "{", "garbage", "{\"v\":1,\"type\":\"X\",\"body\":1}"] {
let _ = c.send_raw_text(s);
}
// The server must still be alive and respond to a valid join.
c.send(&ClientMessage::JoinMatch { name: "after-fuzz".into(), match_id: None })
.expect("send join");
let m = c
.recv_until(|m| matches!(m, protocol::ServerMessage::MatchState { .. }))
.expect("server still serving after fuzz");
assert!(matches!(m, protocol::ServerMessage::MatchState { .. }));
}
+94
View File
@@ -0,0 +1,94 @@
//! Phase H gates: disconnect/reconnect and timer edges. A dropped connection
//! must not corrupt a match, and a late or wrong-turn submission must be
//! rejected deterministically.
use protocol::{Action, ClientMessage, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
#[test]
fn wrong_turn_submission_is_rejected_deterministically() {
let addr = spawn_test_server(2000); // long turn so we control timing
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "timer".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Submit for a turn that is not live.
c.send(&ClientMessage::SubmitTurn { turn: 999, action: Action::Wait }).unwrap();
let report = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match report {
ServerMessage::ValidationReport { accepted, detail, .. } => {
assert!(!accepted, "wrong-turn submission should be rejected");
assert!(detail.contains("wrong turn"), "reason: {detail}");
}
_ => unreachable!(),
}
// A correct, in-time submission is accepted.
c.send(&ClientMessage::SubmitTurn { turn: 0, action: Action::Wait }).unwrap();
let ok = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match ok {
ServerMessage::ValidationReport { accepted, .. } => assert!(accepted),
_ => unreachable!(),
}
}
#[test]
fn disconnect_does_not_corrupt_an_ongoing_duel() {
let addr = spawn_test_server(10);
// Two players share a duel match by id.
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "a".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "b".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Player A plays a couple of turns.
let mut live = 0u64;
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, .. } = r {
live = turn;
}
}
// Player B disconnects abruptly.
drop(b);
// The match continues for A without corruption: more turns still resolve.
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, runtime_hash, .. } = r {
assert!(!runtime_hash.is_empty());
live = turn;
}
}
assert!(live >= 4, "match advanced past a mid-match disconnect");
}
#[test]
fn reconnect_into_open_duel_slot_succeeds() {
let addr = spawn_test_server(50);
let mid = protocol::MatchId(21);
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "host".into(), match_id: Some(mid) }).unwrap();
a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// A second player takes the open slot.
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "guest".into(), match_id: Some(mid) }).unwrap();
let ms = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
if let ServerMessage::MatchState { player_id, .. } = ms {
assert_eq!(player_id.0, 2, "second human should take slot 2");
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Phase H gate: "0 hidden-state leaks". The hidden ground truth must never
//! cross the wire. These tests check the invariant both in-process (the
//! authoritative match) and over the live socket (the serialized bytes).
use game_runtime::{solo_roster, Match};
use protocol::{Action, ClientMessage, MatchId, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::{HIDDEN_LANES, LANES, NUM_DOMAINS};
#[test]
fn visible_snapshot_redacts_all_hidden_lanes() {
let mut m = Match::new(MatchId(1), 4242, solo_roster("dev"));
// Mask half of the observed lanes so redaction is non-trivial too.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
if (d + l) % 2 == 0 {
m.world.observation_state.visible[d][l] = false;
}
}
}
// The generator may already mask lanes; count actual non-visible lanes.
let masked: u32 = (0..NUM_DOMAINS)
.flat_map(|d| (0..LANES).map(move |l| (d, l)))
.filter(|&(d, l)| !m.world.observation_state.visible[d][l])
.count() as u32;
m.set_program(1, vec![protocol::RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 1 }]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
// Every masked observed lane is None.
for vd in &snap.observed_domains {
assert_eq!(vd.observed.len(), LANES);
for (l, o) in vd.observed.iter().enumerate() {
let visible = m.world.observation_state.visible[vd.index as usize][l];
assert_eq!(o.is_some(), visible, "lane visibility/value mismatch");
}
}
// Redaction count accounts for every masked observed lane and every hidden
// lane in the world.
let expected = masked + (NUM_DOMAINS * HIDDEN_LANES) as u32;
assert_eq!(snap.hidden_state_redactions, expected);
// The serialized snapshot must not carry a "hidden" key at all.
let wire = snap.to_json().to_compact();
assert!(!wire.contains("\"hidden\""), "serialized snapshot mentions hidden state");
}
#[test]
fn no_server_message_over_the_wire_carries_hidden_keys() {
let addr = spawn_test_server(12);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "leak-check".into(), match_id: None }).unwrap();
let mut checked = 0;
// Drive a few turns and scan every raw frame the server emits.
let mut live_turn = 0u64;
for _ in 0..6 {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast }).unwrap();
// Read several frames; scan each.
for _ in 0..4 {
let raw = match c.recv_text() {
Ok(r) => r,
Err(_) => break,
};
checked += 1;
assert!(!raw.contains("\"hidden\""), "raw frame leaked hidden key: {raw}");
// Track turn progression from resolved frames.
if let Ok(ServerMessage::TurnResolved { turn, .. }) = ServerMessage::decode(&raw) {
live_turn = turn;
}
}
}
assert!(checked > 0, "no frames scanned");
}