//! Scenario file format and runner (MetricsAndScenarios §2, GameKernel //! K17). Games opt in by implementing [`ScenarioGame`]; [`run`] executes a //! scenario twice with the same seed and fails on hash divergence (K8). use cb_events::{state_hash_hex, Envelope, EventLog}; use cb_kernel::{Actor, Aggregate, EventSeq, GameId, PlayerId}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; /// One scenario file (`scenarios//.yaml`). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScenarioFile { pub scenario: String, #[serde(default)] pub description: String, /// Numbered rule IDs this scenario covers (feeds M-D1-COV / AM-1). pub covers: Vec, /// Marks scenarios encoding a PROVISIONAL U-item default /// (GroundRules §Underdetermined): a ground-game ruling flips the /// scenario, not the kernel. #[serde(default)] pub provisional: bool, pub seed: u64, pub setup: Setup, pub commands: Vec, pub expect: Expect, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Setup { pub players: u8, /// Named setup preset from the game spec (e.g. "standard-3p"). pub preset: String, /// Explicit state overrides applied after the preset (dot-paths). #[serde(default)] pub patch: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CommandStep { pub actor: String, pub cmd: String, #[serde(default)] pub args: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Expect { /// Ordered subsequence of event kinds that must occur. #[serde(default)] pub events: Vec>, /// Partial end-state assertions: dot-path → expected value. #[serde(default)] pub state: BTreeMap, /// Indices into `commands` that must be rejected. #[serde(default)] pub rejects: Vec, /// Optional full-state golden hash. #[serde(default)] pub state_hash: Option, } impl ScenarioFile { pub fn from_yaml(yaml: &str) -> Result { serde_yaml::from_str(yaml) } } /// Result of executing one scenario (twice, per the K8 double-run rule). #[derive(Debug)] pub enum RunOutcome { Passed { covers: Vec }, Failed { reason: String }, } /// A game aggregate the scenario runner can drive (GameKernel K17). The /// game owns setup presets and the command vocabulary; the runner owns /// execution, assertions, and the determinism check. pub trait ScenarioGame: Aggregate + Serialize + serde::de::DeserializeOwned + Sized { /// Build the initial state for a named preset (GR-S rules for GROUND). fn setup(setup: &Setup, seed: u64) -> Result; /// Resolve one scenario step into an actor and a typed command. fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String>; /// Current round, for the event envelope (GameKernel K4). fn round(&self) -> u8; } /// `P1` → `PlayerId(0)`, `SYSTEM` → [`Actor::System`]. Seat numbering is /// 1-based in scenario files and 0-based in state, matching the /// `players.0.*` assertion paths. pub fn parse_actor(actor: &str) -> Result { if actor == "SYSTEM" { return Ok(Actor::System); } actor .strip_prefix('P') .and_then(|n| n.parse::().ok()) .filter(|n| *n >= 1) .map(|n| Actor::Player(PlayerId(n - 1))) .ok_or_else(|| format!("unparseable actor {actor:?} (expected P or SYSTEM)")) } /// One execution pass: returns the end state as JSON, its hash, the /// emitted events, and the indices of rejected commands. struct Pass { state: serde_json::Value, hash: String, events: Vec, rejected: BTreeSet, } fn execute(scenario: &ScenarioFile) -> Result where G: ScenarioGame, G::Event: Serialize, { let mut state = G::setup(&scenario.setup, scenario.seed)?; // The runner owns `setup.patch` so no game reimplements it: overrides // are applied to the canonical form and read back. if !scenario.setup.patch.is_empty() { state = apply_patch(&state, &scenario.setup.patch)?; } let mut log: EventLog = EventLog::new(); let mut events = Vec::new(); let mut rejected = BTreeSet::new(); let mut seq = 0u64; for (index, step) in scenario.commands.iter().enumerate() { let (actor, command) = G::parse_command(step)?; match state.validate(actor, &command) { Err(_) => { rejected.insert(index); } Ok(produced) => { for event in produced { events.push( serde_json::to_value(&event).map_err(|e| format!("event encode: {e}"))?, ); state.fold(&event); // K4/K11: everything applied is logged in envelope order. log.append(Envelope { seq: EventSeq(seq), game_id: GameId(0), round: state.round(), schema_ver: 1, payload: event, }) .map_err(|e| format!("event log: {e}"))?; seq += 1; } } } } Ok(Pass { state: serde_json::to_value(&state).map_err(|e| format!("state encode: {e}"))?, hash: state_hash_hex(&state), events, rejected, }) } /// Execute a scenario against game `G`: run twice with the same seed, /// compare hashes (K8), then check the `expect` block. pub fn run(scenario: &ScenarioFile) -> RunOutcome where G: ScenarioGame, G::Event: Serialize, { let first = match execute::(scenario) { Ok(pass) => pass, Err(reason) => return RunOutcome::Failed { reason }, }; let second = match execute::(scenario) { Ok(pass) => pass, Err(reason) => return RunOutcome::Failed { reason }, }; if first.hash != second.hash { return RunOutcome::Failed { reason: format!( "K8 divergence: run 1 hash {} != run 2 hash {}", first.hash, second.hash ), }; } if let Err(reason) = check(scenario, &first) { return RunOutcome::Failed { reason }; } RunOutcome::Passed { covers: scenario.covers.clone(), } } fn check(scenario: &ScenarioFile, pass: &Pass) -> Result<(), String> { let expected_rejects: BTreeSet = scenario.expect.rejects.iter().copied().collect(); if pass.rejected != expected_rejects { return Err(format!( "rejects mismatch: expected {expected_rejects:?}, got {:?}", pass.rejected )); } for (path, want) in &scenario.expect.state { let want = to_json(want)?; let got = lookup(&pass.state, path) .ok_or_else(|| format!("state path {path:?} not found in end state"))?; if got != &want { return Err(format!("state {path:?}: expected {want}, got {got}")); } } // `expect.events` is an ordered subsequence: each entry must match a // later event than the previous one, by field subset. let mut cursor = 0usize; for want in &scenario.expect.events { let want = to_json(want)?; let found = pass.events[cursor..] .iter() .position(|event| is_subset(&want, event)); match found { Some(offset) => cursor += offset + 1, None => return Err(format!("expected event {want} not found in order")), } } if let Some(want) = &scenario.expect.state_hash { if want != &pass.hash { return Err(format!( "state_hash mismatch: expected {want}, got {}", pass.hash )); } } Ok(()) } /// Apply `setup.patch` dot-path overrides to a freshly built state. Every /// path must already exist — a typo is an error, never a silent no-op. fn apply_patch( state: &G, patch: &BTreeMap, ) -> Result { let mut json = serde_json::to_value(state).map_err(|e| format!("state encode: {e}"))?; for (path, value) in patch { let value = to_json(value)?; let (parent_path, key) = path.rsplit_once('.').unwrap_or(("", path.as_str())); // The parent must exist — a typo mid-path is an error, never a // silent no-op. The final key may be new, so scenarios can seed // open-ended maps (relations, focus) that start empty. let parent = if parent_path.is_empty() { &mut json } else { lookup_mut(&mut json, parent_path).ok_or_else(|| { format!("patch path {path:?}: {parent_path:?} does not exist in the initial state") })? }; match parent { serde_json::Value::Object(map) => { map.insert(key.to_string(), value); } serde_json::Value::Array(items) => { let index = key .parse::() .map_err(|_| format!("patch path {path:?}: {key:?} is not an array index"))?; let slot = items .get_mut(index) .ok_or_else(|| format!("patch path {path:?}: index {index} out of range"))?; *slot = value; } _ => return Err(format!("patch path {path:?}: parent is not a container")), } } serde_json::from_value(json).map_err(|e| format!("patch produced invalid state: {e}")) } fn lookup_mut<'v>( root: &'v mut serde_json::Value, path: &str, ) -> Option<&'v mut serde_json::Value> { path.split('.').try_fold(root, |node, segment| match node { serde_json::Value::Object(map) => map.get_mut(segment), serde_json::Value::Array(items) => items.get_mut(segment.parse::().ok()?), _ => None, }) } fn to_json(value: &T) -> Result { serde_json::to_value(value).map_err(|e| format!("expectation encode: {e}")) } /// Dot-path lookup: object keys and array indices, so `players.0.stress` /// reads the same whether the container is a map or a sequence. fn lookup<'v>(root: &'v serde_json::Value, path: &str) -> Option<&'v serde_json::Value> { path.split('.').try_fold(root, |node, segment| match node { serde_json::Value::Object(map) => map.get(segment), serde_json::Value::Array(items) => items.get(segment.parse::().ok()?), _ => None, }) } /// Field-subset match: every key in `want` must be present and equal in /// `got`, so scenarios can assert on the fields they care about. fn is_subset(want: &serde_json::Value, got: &serde_json::Value) -> bool { match (want, got) { (serde_json::Value::Object(w), serde_json::Value::Object(g)) => w .iter() .all(|(key, value)| g.get(key).is_some_and(|found| is_subset(value, found))), _ => want == got, } } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = r#" scenario: ground/smoke-setup description: Parses the documented scenario shape end to end. covers: [GR-S02, GR-S03] provisional: false seed: 42 setup: players: 3 preset: standard-3p patch: "players.0.stress": 4 commands: - actor: P1 cmd: select_action args: { action: ATTACK, target: P2 } expect: state: "round": 1 rejects: [] "#; #[test] fn scenario_format_roundtrips() { let parsed = ScenarioFile::from_yaml(SAMPLE).unwrap(); assert_eq!(parsed.scenario, "ground/smoke-setup"); assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]); assert_eq!(parsed.setup.players, 3); assert_eq!(parsed.commands.len(), 1); } #[test] fn actors_parse_1_based_into_0_based_seats() { assert_eq!(parse_actor("P1").unwrap(), Actor::Player(PlayerId(0))); assert_eq!(parse_actor("P3").unwrap(), Actor::Player(PlayerId(2))); assert_eq!(parse_actor("SYSTEM").unwrap(), Actor::System); assert!(parse_actor("P0").is_err()); assert!(parse_actor("bogus").is_err()); } #[test] fn dot_paths_index_objects_and_arrays() { let state = serde_json::json!({ "players": { "0": { "stress": 2 } }, "deck": [7, 9] }); assert_eq!(lookup(&state, "players.0.stress").unwrap(), &2); assert_eq!(lookup(&state, "deck.1").unwrap(), &9); assert!(lookup(&state, "players.9.stress").is_none()); } #[test] fn unknown_fields_are_rejected() { let bad = SAMPLE.replace("seed: 42", "seed: 42\ntypo_field: 1"); assert!(ScenarioFile::from_yaml(&bad).is_err()); } }