//! `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>, } /// One recorded turn (Phase G). #[derive(Clone, Debug)] pub struct RecordedTurn { pub turn: u64, pub inputs: Vec, pub turn_hash: Hash, pub events: Vec, } /// The full replay log for a match. #[derive(Clone, Debug)] pub struct ReplayLog { pub seed: u64, pub roster: Vec, pub turns: Vec, 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, pub entities: Vec, pub history: Vec, 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) -> 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) { 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 { // Build a canonical, complete, sorted input set: one action per entity. let mut inputs: Vec = Vec::new(); let mut ids: Vec = 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) { // 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 = 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 = 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 = self .history .iter() .filter(|h| h.contains("cast") || h.contains("working")) .cloned() .collect(); let start = matching.len().saturating_sub(4); let previous_outcomes: Vec = 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 { 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 { 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![ 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"); } } }