From a09d76f370c0fd84fdc6f3496544cbb273a37b70 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 31 Jul 2026 02:14:34 +0200 Subject: [PATCH] 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 --- .claude/ralph-loop.local.md | 21 + crates/cb-game-runtime/src/lib.rs | 2 +- crates/cb-game-runtime/src/scenario.rs | 260 +++++++++++- games/ground/src/lib.rs | 478 ++++++++++++++++++++++- scenarios/ground/gr-r02-select.yaml | 35 ++ scenarios/ground/gr-r03-stress-gate.yaml | 34 ++ tools/cb-sim/src/main.rs | 65 +-- 7 files changed, 848 insertions(+), 47 deletions(-) create mode 100644 .claude/ralph-loop.local.md create mode 100644 scenarios/ground/gr-r02-select.yaml create mode 100644 scenarios/ground/gr-r03-stress-gate.yaml 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); } }