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>
65 lines
2.2 KiB
Rust
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}");
|
|
}
|
|
}
|