//! Scenario file format and runner scaffold (MetricsAndScenarios §2, //! GameKernel K17). The format parses fully; execution against a game //! aggregate is wired up in T08 — until then `run` reports Unimplemented. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// 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, }, /// Scaffold state: parsing works, execution lands in T08. Unimplemented, } /// Runner scaffold. T08 replaces the body with: build aggregate from /// setup, apply commands via validate/fold, check expectations, run twice /// and compare hashes, emit a replay bundle on failure. pub fn run(_scenario: &ScenarioFile) -> RunOutcome { RunOutcome::Unimplemented } #[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); assert!(matches!(run(&parsed), RunOutcome::Unimplemented)); } #[test] fn unknown_fields_are_rejected() { let bad = SAMPLE.replace("seed: 42", "seed: 42\ntypo_field: 1"); assert!(ScenarioFile::from_yaml(&bad).is_err()); } }