T08 iter 1: scenario runner executes; GROUND setup and Select step

Replaces the RunOutcome::Unimplemented stub with a real runner:

- ScenarioGame trait: games own setup presets and the command
  vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
  on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
  monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
  it generically and errors on a path that does not exist, so a typo
  in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
  event subsequence matching by field subset, exact rejects-set match.

GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.

cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-31 02:14:34 +02:00
parent be52250850
commit a09d76f370
7 changed files with 848 additions and 47 deletions

View file

@ -3,7 +3,7 @@
pub mod scenario;
pub use scenario::{RunOutcome, ScenarioFile};
pub use scenario::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
use cb_kernel::PlayerId;
use serde::{Deserialize, Serialize};

View file

@ -1,9 +1,11 @@
//! 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.
//! 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;
use std::collections::{BTreeMap, BTreeSet};
/// One scenario file (`scenarios/<game>/<slug>.yaml`).
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -71,21 +73,227 @@ impl ScenarioFile {
/// Result of executing one scenario (twice, per the K8 double-run rule).
#[derive(Debug)]
pub enum RunOutcome {
Passed {
covers: Vec<String>,
},
Failed {
reason: String,
},
/// Scaffold state: parsing works, execution lands in T08.
Unimplemented,
Passed { covers: Vec<String> },
Failed { reason: String },
}
/// 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
/// 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 slot = lookup_mut(&mut json, path)
.ok_or_else(|| format!("patch path {path:?} does not exist in the initial state"))?;
*slot = value;
}
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)]
@ -120,7 +328,23 @@ expect:
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 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]