Files
magicka-vm/crates/game_runtime/src/lib.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

758 lines
28 KiB
Rust

//! `game_runtime` — the authoritative match layer (Phase B of `plan2.md`).
//!
//! This crate is the *only* place game truth is decided. The browser sends
//! intent; this crate resolves it. Every rune program executes through the
//! **independent** interpreter [`runtime_under_test::native_resolve`] against
//! the shared [`WorldSnapshot`] — the web layer is a window into the Rust
//! universe and never a second simulation. The game does not call the reference
//! engine; correctness of the interpreter it does use is established separately
//! by the runtime-equivalence gate (which compares that interpreter against the
//! reference over a large sweep, with a negative control proving the gate can
//! fail).
//!
//! Two properties are essential and tested:
//! * **Determinism** — a match is a pure function of `(seed, roster, ordered
//! inputs)`. [`replay`] reconstructs any match and produces an identical
//! final hash. No wall clock, no ambient RNG; the turn *timer* lives in the
//! server, never here.
//! * **Authority + visibility** — players receive a [`VisibleWorldSnapshot`]
//! that redacts all hidden lanes and every non-observable observed lane. The
//! hidden ground truth is never placed in any client-bound structure.
use protocol::{
Action, Knowledge, MatchId, RuneDiagnostics, RuneTokenWire, VisibleDomain,
VisibleEntity, VisibleWorldSnapshot,
};
use reference_runtime::{canonical, ResolutionInput, ResolutionResult};
use runtime_under_test::native_resolve;
use rune_ir::{Op, RuneProgram, RuneToken};
use world_model::{
standard_executors, DomainKind, ExecutionContext, Hash, Hasher, ProgramId, Rng, WorldSnapshot,
HIDDEN_LANES, LANES, NUM_DOMAINS,
};
pub const ARENA_W: i32 = 8;
pub const ARENA_H: i32 = 8;
pub const MAX_HP: i32 = 30;
/// Basic stick attack damage.
pub const ATTACK_DAMAGE: i32 = 4;
/// Range (Manhattan) within which a cast's consequence reaches enemies.
pub const CAST_RANGE: i32 = 3;
/// One combatant on the arena. A "player" entity is driven by a connection; a
/// "dummy" is a deterministic stationary target for the 1-player slice.
#[derive(Clone, Debug)]
pub struct Entity {
pub id: u32,
pub name: String,
pub x: i32,
pub y: i32,
pub hp: i32,
pub is_dummy: bool,
/// The player's current editable rune program (Phase D editor state).
pub program: RuneProgram,
}
impl Entity {
pub fn alive(&self) -> bool {
self.hp > 0
}
}
/// A roster entry needed to reconstruct a match for replay.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RosterEntry {
pub id: u32,
pub name: String,
pub is_dummy: bool,
}
/// One resolved turn's authoritative input, sufficient to replay it exactly.
#[derive(Clone, Debug, PartialEq)]
pub struct TurnInput {
pub player: u32,
pub action: Action,
/// The exact program used, captured iff `action` is `Cast`. Recording the
/// program here (rather than replaying editor edits) makes replay a pure
/// function of this input stream.
pub program: Option<Vec<RuneTokenWire>>,
}
/// One recorded turn (Phase G).
#[derive(Clone, Debug)]
pub struct RecordedTurn {
pub turn: u64,
pub inputs: Vec<TurnInput>,
pub turn_hash: Hash,
pub events: Vec<String>,
}
/// The full replay log for a match.
#[derive(Clone, Debug)]
pub struct ReplayLog {
pub seed: u64,
pub roster: Vec<RosterEntry>,
pub turns: Vec<RecordedTurn>,
pub final_hash: Hash,
}
/// The authoritative match state.
#[derive(Clone, Debug)]
pub struct Match {
pub id: MatchId,
pub seed: u64,
pub turn: u64,
pub world: WorldSnapshot,
pub contexts: Vec<ExecutionContext>,
pub entities: Vec<Entity>,
pub history: Vec<String>,
pub replay: ReplayLog,
pub finished: bool,
/// The most recent per-domain observed change, used to tag freshly-observed
/// lanes without storing per-player memory (keeps resolution stateless).
last_observed_delta: [[i64; LANES]; NUM_DOMAINS],
}
/// A blank default program (a single benign token) so every entity always has
/// something to cast.
fn default_program(seed: u64) -> RuneProgram {
let mut rng = Rng::derive(seed, "default-program");
let tokens = (0..8)
.map(|i| RuneToken {
op: Op::from_u8((i as u8).wrapping_add(rng.next_u64() as u8)),
a: rng.next_u64() as u8,
b: rng.next_u64() as u8,
c: rng.next_u64() as u8,
imm: rng.range_i64(-1000, 1000),
})
.collect();
RuneProgram { id: ProgramId(seed), tokens, seed }
}
impl Match {
/// Create a match deterministically from a seed and a roster.
pub fn new(id: MatchId, seed: u64, roster: Vec<RosterEntry>) -> Match {
let world = generators::generate_world(seed);
let contexts = standard_executors(seed, 3);
let mut placer = Rng::derive(seed, "arena-placement");
let mut taken: Vec<(i32, i32)> = Vec::new();
let mut entities = Vec::with_capacity(roster.len());
for entry in &roster {
// Deterministic distinct placement.
let (x, y) = loop {
let x = placer.below(ARENA_W as usize) as i32;
let y = placer.below(ARENA_H as usize) as i32;
if !taken.contains(&(x, y)) {
break (x, y);
}
};
taken.push((x, y));
entities.push(Entity {
id: entry.id,
name: entry.name.clone(),
x,
y,
hp: MAX_HP,
is_dummy: entry.is_dummy,
program: default_program(seed ^ (entry.id as u64).wrapping_mul(0x9e3779b97f4a7c15)),
});
}
Match {
id,
seed,
turn: 0,
world,
contexts,
entities,
history: Vec::new(),
replay: ReplayLog { seed, roster, turns: Vec::new(), final_hash: Hash(0) },
finished: false,
last_observed_delta: [[0; LANES]; NUM_DOMAINS],
}
}
pub fn entity(&self, id: u32) -> Option<&Entity> {
self.entities.iter().find(|e| e.id == id)
}
pub fn entity_mut(&mut self, id: u32) -> Option<&mut Entity> {
self.entities.iter_mut().find(|e| e.id == id)
}
/// Replace a player's editable program (Phase D / `EditRuneProgram`). Length
/// is bounded by the protocol decoder; this just stores it.
pub fn set_program(&mut self, player: u32, tokens: Vec<RuneTokenWire>) {
let seed = self.seed;
if let Some(e) = self.entity_mut(player) {
e.program = RuneProgram {
id: ProgramId(player as u64),
seed: seed ^ player as u64,
tokens: tokens.iter().map(|t| t.into_token()).collect(),
};
}
}
fn resolution_input(&self, program: &RuneProgram) -> ResolutionInput {
ResolutionInput {
world: self.world.clone(),
program: program.clone(),
contexts: self.contexts.clone(),
contract_seed: self.seed,
perturbation_seed: self.seed ^ self.turn,
}
}
/// Resolve one turn from a set of `(player, action)` submissions. Missing
/// players default to `Wait`. Returns the per-turn event list. This is the
/// authoritative state transition and is fully deterministic.
pub fn resolve_turn(&mut self, submissions: &[(u32, Action)]) -> Vec<String> {
// Build a canonical, complete, sorted input set: one action per entity.
let mut inputs: Vec<TurnInput> = Vec::new();
let mut ids: Vec<u32> = self.entities.iter().map(|e| e.id).collect();
ids.sort_unstable();
for id in ids {
let action = submissions
.iter()
.find(|(pid, _)| *pid == id)
.map(|(_, a)| a.clone())
.unwrap_or(Action::Wait);
let program = if matches!(action, Action::Cast) {
self.entity(id)
.map(|e| e.program.tokens.iter().map(RuneTokenWire::from_token).collect())
} else {
None
};
inputs.push(TurnInput { player: id, action, program });
}
let before = self.world.clone();
let mut events = Vec::new();
for input in &inputs {
self.apply_action(input, &mut events);
}
// Knowledge bookkeeping: which observed lanes changed this turn.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
self.last_observed_delta[d][l] =
self.world.domains[d].observed[l].wrapping_sub(before.domains[d].observed[l]);
}
}
self.turn = self.turn.wrapping_add(1);
// Per-turn hash binds every effect: world state + entity state + inputs.
let turn_hash = self.turn_hash(&inputs);
for e in &events {
self.history.push(format!("turn {}: {}", self.turn, e));
}
// End condition: in a multi-player match, finish when at most one
// non-dummy combatant is still standing.
let players = self.entities.iter().filter(|e| !e.is_dummy).count();
let living_players = self.entities.iter().filter(|e| !e.is_dummy && e.alive()).count();
if players >= 2 && living_players <= 1 {
self.finished = true;
}
self.replay.turns.push(RecordedTurn {
turn: self.turn,
inputs,
turn_hash,
events: events.clone(),
});
self.recompute_final_hash();
events
}
fn apply_action(&mut self, input: &TurnInput, events: &mut Vec<String>) {
// Skip dead entities entirely.
let alive = self.entity(input.player).map(|e| e.alive()).unwrap_or(false);
if !alive {
return;
}
match &input.action {
Action::Wait => {}
Action::Move { dx, dy } => {
let (nx, ny) = {
let e = self.entity(input.player).unwrap();
(
(e.x + dx.clamp(&-1, &1)).clamp(0, ARENA_W - 1),
(e.y + dy.clamp(&-1, &1)).clamp(0, ARENA_H - 1),
)
};
let occupied = self
.entities
.iter()
.any(|o| o.id != input.player && o.alive() && o.x == nx && o.y == ny);
if !occupied {
let name = self.entity(input.player).unwrap().name.clone();
let e = self.entity_mut(input.player).unwrap();
e.x = nx;
e.y = ny;
events.push(format!("{name} moved to ({nx},{ny})"));
}
}
Action::Attack { target } => {
let attacker = self.entity(input.player).unwrap().clone();
if let Some(t) = self.entity(*target) {
let adjacent = (t.x - attacker.x).abs() <= 1 && (t.y - attacker.y).abs() <= 1;
if adjacent && t.alive() && *target != input.player {
let tname = t.name.clone();
let te = self.entity_mut(*target).unwrap();
te.hp = (te.hp - ATTACK_DAMAGE).max(0);
let hp = te.hp;
events.push(format!(
"{} struck {} for {ATTACK_DAMAGE} ({} hp left)",
attacker.name, tname, hp
));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
Action::Inspect { target } => {
if let Some(t) = self.entity(*target) {
events.push(format!(
"{} inspected {}",
self.entity(input.player).unwrap().name,
t.name
));
}
}
Action::Cast => {
let program = match &input.program {
Some(toks) => RuneProgram {
id: ProgramId(input.player as u64),
seed: self.seed ^ input.player as u64,
tokens: toks.iter().map(|t| t.into_token()).collect(),
},
None => self.entity(input.player).unwrap().program.clone(),
};
let res = native_resolve(&self.resolution_input(&program));
self.apply_resolution(&res);
let power = cast_power(&res);
let caster = self.entity(input.player).unwrap().clone();
events.push(format!("{} cast a rune program (power {power})", caster.name));
// Consequence: enemies within range take `power` damage.
let targets: Vec<u32> = self
.entities
.iter()
.filter(|o| {
o.id != input.player
&& o.alive()
&& (o.x - caster.x).abs() + (o.y - caster.y).abs() <= CAST_RANGE
})
.map(|o| o.id)
.collect();
for tid in targets {
let tname = self.entity(tid).unwrap().name.clone();
let te = self.entity_mut(tid).unwrap();
te.hp = (te.hp - power).max(0);
let hp = te.hp;
events.push(format!("{tname} took {power} from the working ({hp} hp left)"));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
}
/// Apply a resolution's world delta to the shared world (observed + hidden).
fn apply_resolution(&mut self, res: &ResolutionResult) {
for dd in &res.delta.domain_deltas {
let d = dd.domain.0 as usize;
if d >= NUM_DOMAINS {
continue;
}
for l in 0..LANES {
self.world.domains[d].observed[l] =
self.world.domains[d].observed[l].wrapping_add(dd.observed[l]);
}
for l in 0..HIDDEN_LANES {
self.world.domains[d].hidden[l] =
self.world.domains[d].hidden[l].wrapping_add(dd.hidden[l]);
}
}
}
fn turn_hash(&self, inputs: &[TurnInput]) -> Hash {
let mut h = Hasher::new();
h.write_tag("game-turn");
h.write_u64(self.turn);
h.write_u64(self.world.content_hash().0);
for e in &self.entities {
h.write_u64(e.id as u64);
h.write_i64(e.x as i64);
h.write_i64(e.y as i64);
h.write_i64(e.hp as i64);
}
for input in inputs {
h.write_u64(input.player as u64);
hash_action(&mut h, &input.action);
if let Some(prog) = &input.program {
h.write_usize(prog.len());
for t in prog {
h.write_u8(t.op);
h.write_u8(t.a);
h.write_u8(t.b);
h.write_u8(t.c);
h.write_i64(t.imm);
}
}
}
h.finish()
}
fn recompute_final_hash(&mut self) {
let mut h = Hasher::new();
h.write_tag("game-replay-final");
h.write_u64(self.seed);
for entry in &self.replay.roster {
h.write_u64(entry.id as u64);
h.write_bytes(entry.name.as_bytes());
h.write_u8(entry.is_dummy as u8);
}
for t in &self.replay.turns {
h.write_u64(t.turn_hash.0);
}
self.replay.final_hash = h.finish();
}
/// Hex string of the most recent turn's hash (Phase G `runtime_hash`).
pub fn last_turn_hash_hex(&self) -> String {
format!("{}", self.replay.turns.last().map(|t| t.turn_hash).unwrap_or(Hash(0)))
}
pub fn final_hash_hex(&self) -> String {
format!("{}", self.replay.final_hash)
}
// -- Visibility / knowledge (Phase F) -----------------------------------
/// Build the filtered snapshot for one player. Hidden lanes and non-visible
/// observed lanes are redacted; only their *count* is reported.
pub fn visible_for(&self, player: u32) -> VisibleWorldSnapshot {
let projection = self.world.observed_projection();
let mut observed_domains = Vec::with_capacity(NUM_DOMAINS);
let mut redactions: u32 = 0;
for d in 0..NUM_DOMAINS {
let mut observed = Vec::with_capacity(LANES);
let mut knowledge = Vec::with_capacity(LANES);
for l in 0..LANES {
if self.world.observation_state.visible[d][l] {
observed.push(Some(projection[d * LANES + l]));
knowledge.push(if self.last_observed_delta[d][l] != 0 {
Knowledge::NewlyObserved
} else {
Knowledge::Known
});
} else {
observed.push(None);
knowledge.push(Knowledge::Unknown);
redactions += 1;
}
}
observed_domains.push(VisibleDomain {
index: d as u8,
name: DomainKind::from_index(d).name().to_string(),
observed,
knowledge,
});
}
// All hidden lanes are always withheld.
redactions += (NUM_DOMAINS * HIDDEN_LANES) as u32;
let observed_entities = self
.entities
.iter()
.map(|e| VisibleEntity {
id: e.id,
name: e.name.clone(),
x: e.x,
y: e.y,
hp: e.hp,
is_self: e.id == player,
alive: e.alive(),
})
.collect();
// Inference from *observed* volatility only — never from hidden state.
// Use a presence test (any lane changed) rather than summing magnitudes,
// which avoids overflow on wrapping deltas near i64::MIN.
let mut inferred_markers = Vec::new();
for d in 0..NUM_DOMAINS {
let shifted = (0..LANES).any(|l| self.last_observed_delta[d][l] != 0);
if shifted && self.world.observation_state.visible[d].iter().any(|&v| v) {
inferred_markers.push(format!(
"{} shifted recently — likely volatile",
DomainKind::from_index(d).name()
));
}
}
let known_history: Vec<String> = self.history.iter().rev().take(8).rev().cloned().collect();
VisibleWorldSnapshot {
turn: self.turn,
arena_w: ARENA_W,
arena_h: ARENA_H,
observed_domains,
observed_entities,
observed_environment: vec![
format!("arena {ARENA_W}x{ARENA_H}"),
format!("turn {}", self.turn),
],
known_history,
inferred_markers,
hidden_state_redactions: redactions,
}
}
/// Player-facing diagnostics for a candidate program (Phase D). A *dry run*
/// against a clone of the world — it mutates nothing and never reports
/// hidden values, only domain names, counts, and observed fault risks.
pub fn diagnostics_for(&self, program: &RuneProgram) -> RuneDiagnostics {
let res = native_resolve(&self.resolution_input(program));
let visible_domain = |d: usize| self.world.observation_state.visible[d].iter().any(|&v| v);
let mut known_reads = Vec::new();
for d in res.trace.read_graph.touched() {
if visible_domain(d) {
known_reads.push(DomainKind::from_index(d).name().to_string());
}
}
known_reads.sort();
known_reads.dedup();
let mut known_writes = Vec::new();
let mut unknown_listeners = 0u32;
for d in res.trace.write_graph.touched() {
if visible_domain(d) {
known_writes.push(DomainKind::from_index(d).name().to_string());
} else {
unknown_listeners += 1;
}
}
known_writes.sort();
known_writes.dedup();
let mut observed_risks = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for f in &res.faults.faults {
if seen.insert(f.code.name()) {
observed_risks.push(format!("possible {}", f.code.name()));
}
}
let matching: Vec<String> = self
.history
.iter()
.filter(|h| h.contains("cast") || h.contains("working"))
.cloned()
.collect();
let start = matching.len().saturating_sub(4);
let previous_outcomes: Vec<String> = matching[start..].to_vec();
RuneDiagnostics {
known_reads,
known_writes,
observed_risks,
unknown_listeners,
previous_outcomes,
}
}
/// Diagnostics for a player's currently-stored program.
pub fn diagnostics_for_player(&self, player: u32) -> RuneDiagnostics {
match self.entity(player) {
Some(e) => self.diagnostics_for(&e.program),
None => RuneDiagnostics::default(),
}
}
}
/// Damage power derived from the runtime trace — the rune program's effect on
/// the game is a function of the structure the Rust engine actually produced.
fn cast_power(res: &ResolutionResult) -> i32 {
let rank = res.trace.causal_rank() as i32;
let touched = res.trace.touched_domain_count() as i32;
(1 + rank + touched / 2).clamp(1, 10)
}
fn hash_action(h: &mut Hasher, a: &Action) {
match a {
Action::Wait => h.write_u8(0),
Action::Move { dx, dy } => {
h.write_u8(1);
h.write_i64(*dx as i64);
h.write_i64(*dy as i64);
}
Action::Inspect { target } => {
h.write_u8(2);
h.write_u64(*target as u64);
}
Action::Cast => h.write_u8(3),
Action::Attack { target } => {
h.write_u8(4);
h.write_u64(*target as u64);
}
}
}
/// Build a standard 1-player + dummy roster.
pub fn solo_roster(player_name: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: player_name.to_string(), is_dummy: false },
RosterEntry { id: 2, name: "training dummy".to_string(), is_dummy: true },
]
}
/// Build a 2-player roster.
pub fn duel_roster(a: &str, b: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: a.to_string(), is_dummy: false },
RosterEntry { id: 2, name: b.to_string(), is_dummy: false },
]
}
/// Re-run a match from its seed, roster, and the exact recorded inputs, and
/// return the reconstructed log. Determinism gate: this must reproduce the
/// original `final_hash` bit-for-bit.
pub fn replay(seed: u64, roster: &[RosterEntry], recorded: &[RecordedTurn]) -> ReplayLog {
let mut m = Match::new(MatchId(0), seed, roster.to_vec());
for rt in recorded {
// Restore each casting player's program from the record, then apply the
// same actions in the same order.
for input in &rt.inputs {
if let (Action::Cast, Some(prog)) = (&input.action, &input.program) {
m.set_program(input.player, prog.clone());
}
}
let subs: Vec<(u32, Action)> =
rt.inputs.iter().map(|i| (i.player, i.action.clone())).collect();
m.resolve_turn(&subs);
}
m.replay
}
/// Convenience: run a scripted match end-to-end and return its log. Used by the
/// determinism tests and the headless E2E harness.
pub fn run_scripted(
seed: u64,
roster: &[RosterEntry],
scripts: &[Vec<(u32, Action)>],
) -> (Match, ReplayLog) {
let mut m = Match::new(MatchId(seed), seed, roster.to_vec());
for turn_subs in scripts {
m.resolve_turn(turn_subs);
}
let log = m.replay.clone();
(m, log)
}
/// Canonical fingerprint of a single resolution (used by integration tests to
/// assert the game layer truly drove the independent interpreter).
pub fn resolution_fingerprint(world: &WorldSnapshot, program: &RuneProgram, seed: u64) -> Hash {
let input = ResolutionInput {
world: world.clone(),
program: program.clone(),
contexts: standard_executors(seed, 3),
contract_seed: seed,
perturbation_seed: seed,
};
let c = canonical(&native_resolve(&input));
let mut h = Hasher::new();
h.write_tag("resolution-fp");
h.write_u64(c.delta_hash.0);
h.write_u64(c.trace_hash.0);
h.write_u64(c.replay_hash.0);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn rune(op: u8, a: u8, b: u8, c: u8, imm: i64) -> RuneTokenWire {
RuneTokenWire { op, a, b, c, imm }
}
fn scripted_match() -> Vec<Vec<(u32, Action)>> {
vec![
vec![(1, Action::Move { dx: 1, dy: 0 })],
vec![(1, Action::Cast)],
vec![(1, Action::Attack { target: 2 })],
vec![(1, Action::Wait), (2, Action::Wait)],
vec![(1, Action::Cast)],
]
}
#[test]
fn match_resolves_through_independent_interpreter() {
let mut m = Match::new(MatchId(1), 7, solo_roster("dev"));
m.set_program(1, vec![rune(0, 1, 2, 3, 4), rune(5, 2, 1, 0, -3)]);
let before = m.world.content_hash();
m.resolve_turn(&[(1, Action::Cast)]);
// A cast changed the shared world via the independent interpreter.
assert_ne!(before, m.world.content_hash());
assert_eq!(m.turn, 1);
}
#[test]
fn replay_reproduces_final_hash() {
let seed = 12345;
let roster = solo_roster("dev");
let mut m = Match::new(MatchId(seed), seed, roster.clone());
m.set_program(1, vec![rune(2, 3, 4, 5, 6), rune(8, 1, 1, 1, 1), rune(0, 7, 7, 7, 7)]);
for subs in scripted_match() {
m.resolve_turn(&subs);
}
let original = m.replay.final_hash;
// Replay from the recorded inputs alone.
let reconstructed = replay(seed, &roster, &m.replay.turns);
assert_eq!(original, reconstructed.final_hash, "replay drifted");
}
#[test]
fn many_matches_are_deterministic() {
for seed in 0..200u64 {
let roster = solo_roster("p");
let (m, log) = run_scripted(seed, &roster, &scripted_match());
let again = replay(seed, &roster, &log.turns);
assert_eq!(m.replay.final_hash, again.final_hash, "seed {seed} not deterministic");
}
}
#[test]
fn hidden_state_never_appears_in_visible_snapshot() {
let mut m = Match::new(MatchId(1), 999, solo_roster("dev"));
// Mask some observed lanes so redaction is non-trivial.
for d in 0..NUM_DOMAINS {
m.world.observation_state.visible[d][1] = false;
}
m.set_program(1, vec![rune(10, 1, 2, 3, 4)]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
for vd in &snap.observed_domains {
for (l, o) in vd.observed.iter().enumerate() {
if !m.world.observation_state.visible[vd.index as usize][l] {
assert!(o.is_none(), "masked lane leaked a value");
}
}
}
assert!(snap.hidden_state_redactions >= (NUM_DOMAINS * HIDDEN_LANES) as u32);
}
#[test]
fn diagnostics_are_names_and_counts_only() {
let m = Match::new(MatchId(1), 5, solo_roster("dev"));
let diag = m.diagnostics_for_player(1);
for s in diag.known_reads.iter().chain(diag.known_writes.iter()) {
assert!(s.chars().any(|c| c.is_alphabetic()), "diagnostic should be a domain name");
}
}
}