Files
magicka-vm/crates/web_tests/tests/visibility.rs
T
linus-dandClaude Opus 4.8 9d9d5ce41c 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>
2026-06-21 20:20:46 -07:00

75 lines
3.0 KiB
Rust

//! 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");
}