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>
75 lines
3.0 KiB
Rust
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");
|
|
}
|