diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
new file mode 100644
index 0000000..17e5987
--- /dev/null
+++ b/.claude/ralph-loop.local.md
@@ -0,0 +1,21 @@
+---
+active: true
+iteration: 2
+session_id: 8cbd5701-a096-45a4-a419-9b7b1c9419bc
+max_iterations: 20
+completion_promise: "HEUREKA"
+workplan_id: CB-WP-0001
+workplan_file: workplans/CB-WP-0001-inner-loop.md
+started_at: "2026-07-31T00:08:23Z"
+---
+
+Read the workplan at `workplans/CB-WP-0001-inner-loop.md`.
+
+If every task has `status: done` AND frontmatter `status: done`:
+run `rm -f .claude/ralph-loop.local.md` first (deactivates the loop so the stop hook exits cleanly),
+then output HEUREKA.
+
+Otherwise implement the next `todo` task as described in the workplan.
+Set task `in_progress` when starting, `done` when complete.
+When all tasks are done set frontmatter `status: done`.
+
diff --git a/crates/cb-game-runtime/src/lib.rs b/crates/cb-game-runtime/src/lib.rs
index 6484c9c..4b7d9df 100644
--- a/crates/cb-game-runtime/src/lib.rs
+++ b/crates/cb-game-runtime/src/lib.rs
@@ -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};
diff --git a/crates/cb-game-runtime/src/scenario.rs b/crates/cb-game-runtime/src/scenario.rs
index 5d45cf5..7ced0c9 100644
--- a/crates/cb-game-runtime/src/scenario.rs
+++ b/crates/cb-game-runtime/src/scenario.rs
@@ -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//.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,
- },
- Failed {
- reason: String,
- },
- /// Scaffold state: parsing works, execution lands in T08.
- Unimplemented,
+ Passed { covers: Vec },
+ 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;
+
+ /// 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 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::().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)]
@@ -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]
diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs
index 00d211e..59d9212 100644
--- a/games/ground/src/lib.rs
+++ b/games/ground/src/lib.rs
@@ -1,8 +1,9 @@
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
-//! GameKernel K15–K16). T07 scaffolds the state shell; validate/fold per
-//! GR-rule land in T08 with rule IDs cross-referenced in doc comments.
+//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
+//! comment, giving a greppable rule→code→scenario chain.
-use cb_kernel::PlayerId;
+use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
+use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -11,8 +12,12 @@ use std::collections::BTreeMap;
pub struct PlayerState {
/// GR-F01: clamped 0–5.
pub stress: u8,
- /// GR-F03.
+ /// GR-F03: the Freedom token is READY until spent.
pub freedom_ready: bool,
+ /// GR-R03: set when Freedom is spent this round, lifting the stress
+ /// gate for this Select step only. Cleared at round End.
+ #[serde(default)]
+ pub freedom_gate_lifted: bool,
/// GR-D01/D02: OFF or the pending/active stage.
pub darvo: DarvoStage,
pub hand: Vec,
@@ -75,6 +80,357 @@ pub struct GroundState {
pub solution_discard: Vec,
/// Focus placements: sequence owner → target (GR-T03).
pub focus: BTreeMap,
+ /// GR-R01: which of the four steps the round is in.
+ pub step: RoundStep,
+ /// GR-R02: face-down selections, hidden until Reveal.
+ pub selections: BTreeMap,
+}
+
+/// GR-R01: Select → Reveal → Resolve → End.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RoundStep {
+ Select,
+ Reveal,
+ Resolve,
+ End,
+}
+
+/// GR-R02: one player's face-down choice, with its target where the
+/// Action requires one (GR-A13).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Selection {
+ pub action: Action,
+ pub target: Option,
+ pub problem: Option,
+}
+
+/// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal
+/// (GR-R05), not at Select, so it is not part of the selection.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Action {
+ Investigate,
+ Solve,
+ Support,
+ Attack,
+ Ground,
+}
+
+impl Action {
+ fn parse(raw: &str) -> Result {
+ match raw {
+ "INVESTIGATE" => Ok(Action::Investigate),
+ "SOLVE" => Ok(Action::Solve),
+ "SUPPORT" => Ok(Action::Support),
+ "ATTACK" => Ok(Action::Attack),
+ "GROUND" => Ok(Action::Ground),
+ other => Err(format!("unknown action {other:?}")),
+ }
+ }
+
+ /// GR-R03: the stress gate admits only ATTACK and GROUND.
+ fn allowed_under_stress_gate(self) -> bool {
+ matches!(self, Action::Attack | Action::Ground)
+ }
+
+ /// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and
+ /// SOLVE target a Problem; GROUND targets neither at Select.
+ fn requires_player_target(self) -> bool {
+ matches!(self, Action::Support | Action::Attack)
+ }
+
+ fn requires_problem_target(self) -> bool {
+ matches!(self, Action::Investigate | Action::Solve)
+ }
+}
+
+/// Commands accepted by the GROUND aggregate.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum GroundCommand {
+ /// GR-R02: choose an Action face down.
+ SelectAction {
+ action: Action,
+ target: Option,
+ problem: Option,
+ },
+ /// GR-R03: spend the READY Freedom token to bypass the stress gate.
+ SpendFreedom,
+}
+
+/// Events the aggregate emits. `fold` is total over these (K1).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind")]
+pub enum GroundEvent {
+ ActionSelected {
+ player: PlayerId,
+ selection: Selection,
+ },
+ FreedomSpent {
+ player: PlayerId,
+ },
+}
+
+impl GroundState {
+ /// GR-R03: a player at Stress 4–5 is gated unless Freedom is spent.
+ /// Spending flips the token to SPENT, so the gate returns next round.
+ fn stress_gated(&self, player: &PlayerState) -> bool {
+ let _ = self;
+ player.stress >= 4 && !player.freedom_gate_lifted
+ }
+
+ fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
+ self.players.get(&id).ok_or(Rejection::Game {
+ code: "no-such-seat".into(),
+ detail: format!("player {id} is not in this game"),
+ })
+ }
+}
+
+impl Aggregate for GroundState {
+ type Command = GroundCommand;
+ type Event = GroundEvent;
+
+ fn validate(
+ &self,
+ actor: Actor,
+ command: &Self::Command,
+ ) -> Result, Rejection> {
+ let Actor::Player(id) = actor else {
+ return Err(Rejection::NotAllowedNow);
+ };
+ let player = self.player(id)?;
+
+ match command {
+ // GR-R02: one face-down choice per player, during Select only.
+ GroundCommand::SelectAction {
+ action,
+ target,
+ problem,
+ } => {
+ if self.step != RoundStep::Select {
+ return Err(Rejection::NotAllowedNow);
+ }
+ if self.selections.contains_key(&id) {
+ return Err(Rejection::DuplicateCommand);
+ }
+ if self.stress_gated(player) && !action.allowed_under_stress_gate() {
+ return Err(Rejection::Game {
+ code: "stress-gate".into(),
+ detail: "GR-R03: at Stress 4–5 only ATTACK or GROUND may be selected"
+ .into(),
+ });
+ }
+ self.check_targeting(id, *action, *target, *problem)?;
+ Ok(vec![GroundEvent::ActionSelected {
+ player: id,
+ selection: Selection {
+ action: *action,
+ target: *target,
+ problem: *problem,
+ },
+ }])
+ }
+ // GR-R03: spendable during Select, before Reveal, once.
+ GroundCommand::SpendFreedom => {
+ if self.step != RoundStep::Select {
+ return Err(Rejection::NotAllowedNow);
+ }
+ if !player.freedom_ready {
+ return Err(Rejection::Game {
+ code: "freedom-spent".into(),
+ detail: "GR-F03: the Freedom token is already SPENT".into(),
+ });
+ }
+ Ok(vec![GroundEvent::FreedomSpent { player: id }])
+ }
+ }
+ }
+
+ fn fold(&mut self, event: &Self::Event) {
+ match event {
+ GroundEvent::ActionSelected { player, selection } => {
+ self.selections.insert(*player, *selection);
+ }
+ GroundEvent::FreedomSpent { player } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.freedom_ready = false;
+ state.freedom_gate_lifted = true;
+ }
+ }
+ }
+ }
+}
+
+impl GroundState {
+ /// GR-A13 targeting legality, shared by every Action.
+ fn check_targeting(
+ &self,
+ actor: PlayerId,
+ action: Action,
+ target: Option,
+ problem: Option,
+ ) -> Result<(), Rejection> {
+ let bad = |detail: String| Rejection::Game {
+ code: "bad-target".into(),
+ detail,
+ };
+
+ if action.requires_player_target() {
+ let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?;
+ if target == actor {
+ return Err(bad(
+ "GR-A13: SUPPORT and ATTACK target another player".into()
+ ));
+ }
+ if !self.players.contains_key(&target) {
+ return Err(bad(format!("GR-A13: player {target} is not in this game")));
+ }
+ } else if target.is_some() {
+ return Err(bad(format!("GR-A13: {action:?} takes no player target")));
+ }
+
+ if action.requires_problem_target() {
+ let problem =
+ problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?;
+ if !self.problems.contains_key(&problem) {
+ return Err(bad(format!("GR-A13: no Problem {problem}")));
+ }
+ } else if problem.is_some() {
+ return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
+ }
+
+ Ok(())
+ }
+}
+
+/// GR-S01: hidden-Problem priorities admitted per player count.
+fn problem_priorities(players: u8) -> Result {
+ match players {
+ 2 => Ok(2),
+ 3..=4 => Ok(3),
+ 5..=6 => Ok(4),
+ other => Err(format!("GR-S01: unsupported player count {other}")),
+ }
+}
+
+/// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order
+/// before the seeded shuffle.
+fn core_solution_deck() -> Vec {
+ [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
+ .into_iter()
+ .flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
+ .collect()
+}
+
+impl ScenarioGame for GroundState {
+ /// GR-S01..S04. The `standard-Np` presets differ only in seat count;
+ /// Problem content is scenario data, so the preset uses the canonical
+ /// fixture below (suit cycling by priority) until scenario decks are
+ /// modelled.
+ fn setup(setup: &Setup, seed: u64) -> Result {
+ let seats = setup.players;
+ let expected = format!("standard-{seats}p");
+ if setup.preset != expected {
+ return Err(format!(
+ "preset {:?} does not match {seats} players (expected {expected:?})",
+ setup.preset
+ ));
+ }
+ let priorities = problem_priorities(seats)?;
+ let mut rng = ChaChaRng::from_seed(Seed(seed));
+
+ // GR-S04: shuffle first, then deal, so the deal is seed-derived.
+ let mut deck = core_solution_deck();
+ rng.shuffle(&mut deck);
+
+ // GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards.
+ let mut players = BTreeMap::new();
+ for seat in 0..seats {
+ let hand = deck.split_off(deck.len() - 2);
+ players.insert(
+ PlayerId(seat),
+ PlayerState {
+ stress: 2,
+ freedom_ready: true,
+ freedom_gate_lifted: false,
+ darvo: DarvoStage::Off,
+ hand,
+ protection: 0,
+ blame_from: vec![],
+ },
+ );
+ }
+
+ // GR-S01: priority 1 is the Surface Problem, face up; the rest
+ // start face down.
+ let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change];
+ let problems = (1..=u32::from(priorities))
+ .map(|priority| {
+ (
+ priority,
+ ProblemState {
+ suit: suits[(priority as usize - 1) % suits.len()],
+ value: priority as u8,
+ face_up: priority == 1,
+ denied: false,
+ claimed_by: None,
+ protected_this_round: false,
+ },
+ )
+ })
+ .collect();
+
+ // GR-S03: seeded-random Lead, Round 1.
+ let lead = PlayerId(rng.draw(u32::from(seats)) as u8);
+
+ Ok(GroundState {
+ round: 1,
+ lead,
+ players,
+ relations: BTreeMap::new(),
+ problems,
+ solution_deck: deck,
+ solution_discard: vec![],
+ focus: BTreeMap::new(),
+ step: RoundStep::Select,
+ selections: BTreeMap::new(),
+ })
+ }
+
+ fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> {
+ let actor = parse_actor(&step.actor)?;
+ let arg_str = |key: &str| -> Result {
+ step.args
+ .get(key)
+ .and_then(|v| v.as_str().map(str::to_string))
+ .ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd))
+ };
+ let arg_u64 = |key: &str| -> Option { step.args.get(key).and_then(|v| v.as_u64()) };
+
+ let command = match step.cmd.as_str() {
+ "select_action" => {
+ let action = Action::parse(&arg_str("action")?)?;
+ let target = match step.args.get("target") {
+ Some(_) => match parse_actor(&arg_str("target")?)? {
+ Actor::Player(id) => Some(id),
+ Actor::System => return Err("target may not be SYSTEM".into()),
+ },
+ None => None,
+ };
+ GroundCommand::SelectAction {
+ action,
+ target,
+ problem: arg_u64("problem").map(|p| p as u32),
+ }
+ }
+ "spend_freedom" => GroundCommand::SpendFreedom,
+ other => return Err(format!("unknown command {other:?}")),
+ };
+ Ok((actor, command))
+ }
+
+ fn round(&self) -> u8 {
+ self.round
+ }
}
#[cfg(test)]
@@ -91,6 +447,7 @@ mod tests {
PlayerState {
stress: 2,
freedom_ready: true,
+ freedom_gate_lifted: false,
darvo: DarvoStage::Off,
hand: vec![SolutionCard { suit: Suit::Repair }],
protection: 0,
@@ -102,9 +459,122 @@ mod tests {
solution_deck: vec![],
solution_discard: vec![],
focus: BTreeMap::new(),
+ step: RoundStep::Select,
+ selections: BTreeMap::new(),
}
}
+ fn setup_3p(seed: u64) -> GroundState {
+ GroundState::setup(
+ &Setup {
+ players: 3,
+ preset: "standard-3p".into(),
+ patch: BTreeMap::new(),
+ },
+ seed,
+ )
+ .unwrap()
+ }
+
+ /// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
+ /// and the deck loses exactly what was dealt.
+ #[test]
+ fn setup_deals_per_gr_s02_and_s04() {
+ let state = setup_3p(42);
+ assert_eq!(state.players.len(), 3);
+ assert_eq!(state.round, 1);
+ for player in state.players.values() {
+ assert_eq!(player.stress, 2);
+ assert!(player.freedom_ready);
+ assert_eq!(player.darvo, DarvoStage::Off);
+ assert_eq!(player.hand.len(), 2);
+ }
+ assert_eq!(state.solution_deck.len(), 24 - 6);
+ // GR-S01: 3 players → priorities 1–3, priority 1 face up.
+ assert_eq!(state.problems.len(), 3);
+ assert!(state.problems[&1].face_up);
+ assert!(!state.problems[&2].face_up);
+ }
+
+ /// GR-S03/S04: the same seed reproduces setup exactly; a different
+ /// seed does not.
+ #[test]
+ fn setup_is_seed_deterministic() {
+ assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42)));
+ assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7)));
+ }
+
+ /// GR-R02: one selection per player per round.
+ #[test]
+ fn second_selection_is_a_duplicate() {
+ let mut state = setup_3p(42);
+ let cmd = GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(1)),
+ problem: None,
+ };
+ let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap();
+ for event in &events {
+ state.fold(event);
+ }
+ assert_eq!(
+ state.validate(Actor::Player(PlayerId(0)), &cmd),
+ Err(Rejection::DuplicateCommand)
+ );
+ }
+
+ /// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending
+ /// Freedom lifts it.
+ #[test]
+ fn stress_gate_blocks_until_freedom_is_spent() {
+ let mut state = setup_3p(42);
+ state.players.get_mut(&PlayerId(0)).unwrap().stress = 4;
+ let support = GroundCommand::SelectAction {
+ action: Action::Support,
+ target: Some(PlayerId(1)),
+ problem: None,
+ };
+ let attack = GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(1)),
+ problem: None,
+ };
+
+ assert!(matches!(
+ state.validate(Actor::Player(PlayerId(0)), &support),
+ Err(Rejection::Game { ref code, .. }) if code == "stress-gate"
+ ));
+ // ATTACK is always admitted by the gate.
+ assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok());
+
+ let spent = state
+ .validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom)
+ .unwrap();
+ for event in &spent {
+ state.fold(event);
+ }
+ assert!(!state.players[&PlayerId(0)].freedom_ready);
+ assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok());
+ }
+
+ /// GR-A13: SUPPORT and ATTACK may not target their own player.
+ #[test]
+ fn self_targeting_is_rejected() {
+ let state = setup_3p(42);
+ let result = state.validate(
+ Actor::Player(PlayerId(0)),
+ &GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(0)),
+ problem: None,
+ },
+ );
+ assert!(matches!(
+ result,
+ Err(Rejection::Game { ref code, .. }) if code == "bad-target"
+ ));
+ }
+
/// K7 on the real aggregate: hash stable across clones, sensitive to
/// semantic change.
#[test]
diff --git a/scenarios/ground/gr-r02-select.yaml b/scenarios/ground/gr-r02-select.yaml
new file mode 100644
index 0000000..94b22bd
--- /dev/null
+++ b/scenarios/ground/gr-r02-select.yaml
@@ -0,0 +1,35 @@
+scenario: ground/gr-r02-select
+description: >
+ Select step: each player commits exactly one face-down Action, and a
+ second commit from the same player is rejected as a duplicate.
+covers: [GR-R02, GR-A13]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: ATTACK, target: P2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: SUPPORT, target: P3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ # GR-R02: one selection per player per round.
+ - actor: P1
+ cmd: select_action
+ args: { action: GROUND }
+expect:
+ events:
+ - kind: ActionSelected
+ player: 0
+ - kind: ActionSelected
+ player: 1
+ state:
+ "selections.0.action": Attack
+ "selections.1.action": Support
+ "selections.2.problem": 2
+ rejects: [3]
diff --git a/scenarios/ground/gr-r03-stress-gate.yaml b/scenarios/ground/gr-r03-stress-gate.yaml
new file mode 100644
index 0000000..7562ff4
--- /dev/null
+++ b/scenarios/ground/gr-r03-stress-gate.yaml
@@ -0,0 +1,34 @@
+scenario: ground/gr-r03-stress-gate
+description: >
+ Stress gate: at Stress 4 only ATTACK or GROUND may be selected, until
+ the READY Freedom token is spent, which admits any one Action.
+covers: [GR-R03, GR-F03]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "players.0.stress": 4
+commands:
+ # Gated: SUPPORT is not admitted at Stress 4.
+ - actor: P1
+ cmd: select_action
+ args: { action: SUPPORT, target: P2 }
+ - actor: P1
+ cmd: spend_freedom
+ # The same Action is now legal, and the token reads SPENT.
+ - actor: P1
+ cmd: select_action
+ args: { action: SUPPORT, target: P2 }
+expect:
+ events:
+ - kind: FreedomSpent
+ player: 0
+ - kind: ActionSelected
+ player: 0
+ state:
+ "players.0.stress": 4
+ "players.0.freedom_ready": false
+ "selections.0.action": Support
+ rejects: [0]
diff --git a/tools/cb-sim/src/main.rs b/tools/cb-sim/src/main.rs
index d881eca..633d1aa 100644
--- a/tools/cb-sim/src/main.rs
+++ b/tools/cb-sim/src/main.rs
@@ -1,9 +1,10 @@
//! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`).
-//! T07 scope: parse and validate scenario files, report coverage tags.
-//! T08 wires execution. Exit codes: 0 all passed, 1 failures, 2 not yet
-//! executable (parse-only), 64 usage error.
+//! Parses scenario files, dispatches each to its game by the `/`
+//! prefix of its `scenario` field, and executes it. Exit codes: 0 all
+//! passed, 1 failures, 2 unknown game, 64 usage error.
use cb_game_runtime::{scenario, RunOutcome, ScenarioFile};
+use games_ground::GroundState;
fn main() {
let args: Vec = std::env::args().skip(1).collect();
@@ -12,8 +13,10 @@ fn main() {
std::process::exit(64);
}
- let mut unimplemented = false;
+ let mut unknown_game = false;
let mut failed = false;
+ let mut passed = 0usize;
+ let mut covered: Vec = Vec::new();
for path in &args {
let text = match std::fs::read_to_string(path) {
@@ -24,36 +27,50 @@ fn main() {
continue;
}
};
- match ScenarioFile::from_yaml(&text) {
+ let sc = match ScenarioFile::from_yaml(&text) {
Err(e) => {
eprintln!("{path}: parse error: {e}");
failed = true;
+ continue;
+ }
+ Ok(sc) => sc,
+ };
+
+ let outcome = match sc.scenario.split('/').next() {
+ Some("ground") => scenario::run::(&sc),
+ _ => {
+ println!("SKIP {} — no game registered for this prefix", sc.scenario);
+ unknown_game = true;
+ continue;
+ }
+ };
+
+ match outcome {
+ RunOutcome::Passed { covers } => {
+ println!(
+ "PASS {} covers={}{}",
+ sc.scenario,
+ covers.join(","),
+ if sc.provisional { " provisional" } else { "" }
+ );
+ covered.extend(covers);
+ passed += 1;
+ }
+ RunOutcome::Failed { reason } => {
+ println!("FAIL {} — {reason}", sc.scenario);
+ failed = true;
}
- Ok(sc) => match scenario::run(&sc) {
- RunOutcome::Passed { covers } => {
- println!("PASS {} covers={}", sc.scenario, covers.join(","));
- }
- RunOutcome::Failed { reason } => {
- println!("FAIL {} — {reason}", sc.scenario);
- failed = true;
- }
- RunOutcome::Unimplemented => {
- println!(
- "PARSED {} covers={}{} (runner not yet implemented — T08)",
- sc.scenario,
- sc.covers.join(","),
- if sc.provisional { " provisional" } else { "" }
- );
- unimplemented = true;
- }
- },
}
}
+ covered.sort();
+ covered.dedup();
+ println!("{passed} passed, {} rules covered", covered.len());
+
if failed {
std::process::exit(1);
}
- if unimplemented {
+ if unknown_game {
std::process::exit(2);
}
}