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>
758 lines
28 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|