Files
magicka-vm/crates/web_tests/tests/determinism.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

65 lines
2.2 KiB
Rust

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