Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
13 KiB
Rust
378 lines
13 KiB
Rust
//! 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/<game>/<slug>.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<String>,
|
|
/// 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<CommandStep>,
|
|
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<String, serde_yaml::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CommandStep {
|
|
pub actor: String,
|
|
pub cmd: String,
|
|
#[serde(default)]
|
|
pub args: BTreeMap<String, serde_yaml::Value>,
|
|
}
|
|
|
|
#[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<BTreeMap<String, serde_yaml::Value>>,
|
|
/// Partial end-state assertions: dot-path → expected value.
|
|
#[serde(default)]
|
|
pub state: BTreeMap<String, serde_yaml::Value>,
|
|
/// Indices into `commands` that must be rejected.
|
|
#[serde(default)]
|
|
pub rejects: Vec<usize>,
|
|
/// Optional full-state golden hash.
|
|
#[serde(default)]
|
|
pub state_hash: Option<String>,
|
|
}
|
|
|
|
impl ScenarioFile {
|
|
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
|
|
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<String> },
|
|
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<Self, String>;
|
|
|
|
/// 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<Actor, String> {
|
|
if actor == "SYSTEM" {
|
|
return Ok(Actor::System);
|
|
}
|
|
actor
|
|
.strip_prefix('P')
|
|
.and_then(|n| n.parse::<u8>().ok())
|
|
.filter(|n| *n >= 1)
|
|
.map(|n| Actor::Player(PlayerId(n - 1)))
|
|
.ok_or_else(|| format!("unparseable actor {actor:?} (expected P<n> 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<serde_json::Value>,
|
|
rejected: BTreeSet<usize>,
|
|
}
|
|
|
|
fn execute<G>(scenario: &ScenarioFile) -> Result<Pass, String>
|
|
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<G::Event> = 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<G>(scenario: &ScenarioFile) -> RunOutcome
|
|
where
|
|
G: ScenarioGame,
|
|
G::Event: Serialize,
|
|
{
|
|
let first = match execute::<G>(scenario) {
|
|
Ok(pass) => pass,
|
|
Err(reason) => return RunOutcome::Failed { reason },
|
|
};
|
|
let second = match execute::<G>(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<usize> = 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<G: ScenarioGame>(
|
|
state: &G,
|
|
patch: &BTreeMap<String, serde_yaml::Value>,
|
|
) -> Result<G, String> {
|
|
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::<usize>()
|
|
.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::<usize>().ok()?),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, String> {
|
|
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::<usize>().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());
|
|
}
|
|
}
|