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>
146 lines
6.0 KiB
Rust
146 lines
6.0 KiB
Rust
//! 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");
|
|
}
|