diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
new file mode 100644
index 0000000..bf5f62a
--- /dev/null
+++ b/.claude/ralph-loop.local.md
@@ -0,0 +1,21 @@
+---
+active: true
+iteration: 3
+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/Makefile b/Makefile
index 1cf6521..2975860 100644
--- a/Makefile
+++ b/Makefile
@@ -15,6 +15,9 @@ test:
$(CARGO) test --workspace
## run all GROUND scenarios through cb-sim
+coverage:
+ python3 tools/rule-coverage.py
+
sim:
$(CARGO) run -q -p cb-sim -- scenarios/ground/*.yaml
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..1a6aa08 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,250 @@ 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 (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::()
+ .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::().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 +351,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/evidence/CB-EV-0001-game-kernel.md b/evidence/CB-EV-0001-game-kernel.md
new file mode 100644
index 0000000..f69af30
--- /dev/null
+++ b/evidence/CB-EV-0001-game-kernel.md
@@ -0,0 +1,193 @@
+# CB-EV-0001 — GROUND game kernel: acceptance evidence
+
+Status: **T08 complete, with one acceptance metric not met (AM-4).**
+Recorded: 2026-07-31
+Workplan: CB-WP-0001, task T08
+Spec: `specs/GameKernel.md` §4 (AM-1..AM-12)
+Baseline: `research/CB-RES-0001-game-kernel.md`, measurements in
+`research/CB-RES-0001-harness/boardgame-io/results-260731.json`
+
+Machine: WSL2, Linux 6.18.33.2-microsoft-standard-WSL2, rustc 1.97.1,
+`--release`, Criterion 1s warm-up / 3s measurement.
+
+## 1. Scoreboard
+
+| Metric | Target | Measured | Verdict |
+|---|---|---|---|
+| AM-1 rule coverage | 100% of GR-rules | 58/58 (100%) | **met** |
+| AM-4 dependency weight | ≤20 crates | 33 | **NOT MET** |
+| AM-6 throughput | ≥100,000 events/s | 1,651,400 events/s | **met, 16.5×** |
+| AM-7 scaling | ≥0.9× at 20× workload | 1.08× | **met** |
+| AM-7 replay | 100k events ≤5s | 4.13 ms | **met, 1,210×** |
+| AM-8 determinism | zero divergence, 10 replays | 1 distinct hash / 10 runs | **met** |
+| AM-8 lint | fmt + clippy clean | clean, `-D warnings` | **met** |
+| AM-10 foreign types | zero `HashMap`/`HashSet` | 0 | **met** |
+| AM-11 impl pairs | null + reference per port | 1 of 1 (`KernelRng`) | **met, narrow** |
+| AM-12 cost log | present | §5 | **met** |
+
+AM-2, AM-3, AM-5 and AM-9 are not reported: see §6.
+
+## 2. Throughput and scaling (AM-6, AM-7)
+
+Workload: 3-player GROUND rounds, 7 commands and 13 events per round
+(12 on a game's fifth round, where GR-R09 ends the game). Pinned by
+`bench_shape` in `games/ground/src/lib.rs`, so a change to the workload
+breaks the test rather than silently rescaling the metric.
+
+| Rounds | Throughput (events/s) | vs 5k |
+|---|---|---|
+| 5,000 | 1,524,200 | 1.00× |
+| 10,000 | 1,638,200 | 1.07× |
+| 20,000 | 1,577,000 | 1.03× |
+| 40,000 | 1,626,000 | 1.07× |
+| 100,000 | 1,651,400 | 1.08× |
+
+Replay — folding one growing event log back into state:
+
+| Events | Time | Rate |
+|---|---|---|
+| 10,010 | 465 µs | 21.5M events/s |
+| 100,007 | 4.13 ms | 24.2M events/s |
+
+### The comparison against boardgame.io, stated carefully
+
+boardgame.io measured **1,930 moves/s at 5,000 moves**, falling to
+**870 moves/s at 20,000**, and **did not finish 100,000 moves in 300 s**.
+Our figure in the same unit is ~129,000 rounds/s × 7 = **~903,000
+commands/s**, and 100,000 rounds complete in 776 ms.
+
+That is roughly a 400–500× ratio, and it is **not a like-for-like
+measurement**. Four differences matter, all favouring us:
+
+1. **Different language and process model.** Rust in-process against
+ Node.js with immutable state, patch generation and undo history.
+2. **Different feature set.** boardgame.io's per-move cost includes
+ producing client patches and maintaining undo state; the run with
+ `--disable-undo` still degraded (0.66× at 40k). We do neither.
+3. **Different workload shape.** Our 5-round games (GR-R09) bound state
+ size by construction. The boardgame.io harness ran one match with
+ unbounded history, which is exactly the axis it degraded on.
+4. **No network or storage layer** on our side.
+
+Point 3 is the important one and it is why the flat curve in the table
+above is *weak evidence on its own* — a game that resets every five
+rounds cannot exhibit history-growth degradation. The replay benchmark
+is the honest test of that axis, because there the log grows without
+bound, and it stays linear (21.5M → 24.2M events/s from 10k to 100k).
+
+**Claim we are willing to defend:** the kernel meets AM-6 and AM-7 with
+large margin, and does not degrade as event-log length grows.
+**Claim we are not making:** that Clay-Borg is ~450× "faster than
+boardgame.io" as a like-for-like engine comparison. Per the
+InnerLoop parity-cap rule, a cross-runtime ratio this coarse is not a
+verdict, it is a direction.
+
+### A measurement error found and corrected
+
+The first run of this benchmark reported **9.3M events/s with a
+perfectly flat curve** — a number that would have been reported as a
+93× beat of AM-6. It was wrong. The workload had P2 selecting SUPPORT
+every round while being attacked; after three rounds P2 sat at Stress 4,
+GR-R03 rejected the SUPPORT, the round never completed, and the loop
+spun on rejected commands. Throughput was computed as
+`rounds × 13 events` while most rounds produced 2.
+
+Found by a probe test asserting that a round produces events at all.
+The benchmark now asserts the per-round event count on every round and
+panics rather than measuring a stalled loop. The corrected figure is
+**5.6× lower** than the bogus one.
+
+## 3. Determinism (AM-8)
+
+- Every scenario runs twice per invocation with the same seed and fails
+ on state-hash divergence (K8). 21/21 pass.
+- Ten consecutive full runs of all 21 scenarios produced **one distinct
+ output hash**, i.e. zero divergence.
+- `cargo fmt --check` and `cargo clippy --workspace --all-targets
+ -D warnings` are clean.
+- `clippy.toml` denies `HashMap`/`HashSet` workspace-wide (K6); the
+ aggregate holds only ordered collections, so iteration order cannot
+ vary between runs.
+
+## 4. AM-4 — not met, and why it is reported rather than fixed
+
+**33 transitive crates against a ≤20 target.** The baseline it was set
+against is boardgame.io's 120 npm packages, so we are 3.6× lighter, but
+the metric as written is missed and is recorded as missed.
+
+Attribution:
+
+| Group | Crates | Count |
+|---|---|---|
+| `sha2` (K7 state hashing) | sha2, digest, block-buffer, crypto-common, generic-array, typenum, cpufeatures, cfg-if | 8 |
+| serde derive chain | serde_derive, proc-macro2, quote, syn, unicode-ident | 5 |
+| serde runtime + json | serde, serde_core, serde_json, itoa, ryu, memchr, zmij | 7 |
+| `serde_yaml` (scenario files only) | serde_yaml, unsafe-libyaml, indexmap, hashbrown, equivalent | 5 |
+| `rand_chacha` (K5 seeded RNG) | rand_chacha, rand_core, ppv-lite86, zerocopy | 4 |
+| Clay-Borg crates | cb-kernel, cb-events, cb-game-runtime, games-ground | 4 |
+
+The honest options, in order of preference:
+
+1. **Make `serde_yaml` optional** behind a `scenarios` feature. YAML is
+ a test-and-tooling concern; a shipped game runtime does not need it.
+ Removes 5 crates from the default build for no loss of capability.
+ This is the one to do first, and it improves D4 optionality as well
+ as D2.
+2. **Revisit the target.** ≤20 was set before the K5/K7 contracts named
+ ChaCha and SHA-256. Those two contracts cost 12 crates between them
+ and are load-bearing for determinism. A target that a spec's own
+ contracts make unreachable is a bad target.
+
+What we are **not** doing: hand-rolling SHA-256 or ChaCha to win a
+dependency count. That trades an auditable, well-tested primitive for a
+number on a scoreboard.
+
+This is a T09 input: either the metric moves for a stated reason, or
+option 1 lands and the remainder is justified.
+
+## 5. Cost log (AM-12)
+
+Per `specs/MetricsAndScenarios.md` §1a. Model: Claude Fable 5, at
+`benchmarks/baselines/model-prices.toml` rates ($10/$50 per MTok).
+
+| Task | Model | Iterations | Notes |
+|---|---|---|---|
+| T08 | claude-fable-5 | 6 code iterations + benchmarks | Token counts not captured per iteration; see limitation below |
+
+**Limitation, stated rather than fabricated:** exact per-task token
+counts were not instrumented during T08, so the USD figure the metric
+asks for cannot be computed honestly from this run. Recording an
+estimate here would defeat the purpose of the metric. T09 should either
+wire real token accounting into the loop or drop M-D2-CST as
+unmeasurable in this setup.
+
+## 6. Metrics not reported
+
+- **AM-2, AM-3, AM-5** — specification-quality metrics that need a
+ second capability to compare against; a single data point is not a
+ measurement.
+- **AM-9 (≤64MB)** — not instrumented. The aggregate is a few KB and
+ the largest log measured here is 100k events, so the budget is very
+ unlikely to bind, but "unlikely" is not "measured" and it is left
+ unclaimed.
+- **AM-11** — the `KernelRng` null/reference pair exists and is
+ exercised. It is the only port with a pair so far, so the metric is
+ met narrowly and will mean more once storage has one.
+
+## 7. Rules implemented under a provisional default
+
+Ten U-items in `specs/GroundRules.md` carry PROVISIONAL defaults. Those
+realized here are U2 (clamp on every application), U3 (DENY with no
+legal target is a no-op that still advances), U4 (deck reshuffle), U5
+(REVERSE owner relief applies whether or not the Reverse was rejected)
+and U8 (GROUND—OU cancellation precedes Protection).
+
+One further ambiguity was found during T08 and is **not** in the U-list:
+**GR-E02's "successes"** is undefined in dataset 0.1. It is implemented
+as the count of claimed Problems. Both scoring scenarios are marked
+`provisional: true`.
+
+All provisional behaviour lives behind named functions and is covered by
+scenarios tagged `provisional: true`, so a ground-game ruling flips a
+scenario rather than the kernel (K16). **Action for ground-game:** rule
+on the ten U-items and on GR-E02's "successes".
diff --git a/games/ground/benches/synthetic.rs b/games/ground/benches/synthetic.rs
index b1700b6..8a6d596 100644
--- a/games/ground/benches/synthetic.rs
+++ b/games/ground/benches/synthetic.rs
@@ -1,48 +1,219 @@
-//! Criterion skeleton for AM-6/AM-7 (GameKernel §4), wired to the
-//! CB-RES-0001 baseline shape (3-player commit/reveal synthetic workload).
-//! T08 replaces the placeholder body with the real aggregate loop; the
-//! bench IDs and workload sizes are fixed here so results stay comparable
-//! to benchmarks/baselines/ recordings.
+//! AM-6/AM-7 benchmarks (GameKernel §4): the real GROUND aggregate under
+//! the CB-RES-0001 synthetic workload (3-player commit/reveal rounds).
+//!
+//! The baseline is boardgame.io, recorded in
+//! research/CB-RES-0001-harness/boardgame-io/results-260731.json. That
+//! harness measured moves/second *while history grew*, and its headline
+//! finding was that throughput halved as history doubled. AM-7 exists
+//! because of that finding, so the same workload sizes are run here:
+//! what matters is the shape of the curve, not only the peak number.
-use cb_events::state_hash;
-use cb_game_runtime::CommitWindow;
-use cb_kernel::PlayerId;
-use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
+use cb_events::state_hash_hex;
+use cb_game_runtime::{ScenarioGame, Setup};
+use cb_kernel::{Actor, Aggregate, PlayerId};
+use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput};
+use games_ground::{Action, GroundCommand, GroundMode, GroundState};
+use std::collections::BTreeMap;
-/// Placeholder workload: open/submit/reveal one 3-player commit window and
-/// hash a small value. Exists so the bench harness, IDs, and baseline
-/// wiring compile and run before the aggregate exists.
-fn commit_reveal_round() -> usize {
- let mut w: CommitWindow = CommitWindow::open([PlayerId(0), PlayerId(1), PlayerId(2)]);
- for p in 0..3u8 {
- w.submit(PlayerId(p), p).unwrap();
- }
- let revealed = w.reveal().unwrap();
- state_hash(&revealed.len()).len()
+fn setup(seed: u64) -> GroundState {
+ GroundState::setup(
+ &Setup {
+ players: 3,
+ preset: "standard-3p".to_string(),
+ patch: BTreeMap::new(),
+ },
+ seed,
+ )
+ .expect("standard-3p setup")
}
+fn apply(state: &mut GroundState, actor: Actor, command: &GroundCommand) -> usize {
+ match state.validate(actor, command) {
+ Ok(events) => {
+ for event in &events {
+ state.fold(event);
+ }
+ events.len()
+ }
+ Err(_) => 0,
+ }
+}
+
+/// One full round for three players: three Selects, Reveal, the GROUND
+/// mode choice, Resolve, End. Returns the number of events applied.
+fn play_round(state: &mut GroundState) -> usize {
+ let picks = [
+ (
+ PlayerId(0),
+ GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(1)),
+ problem: None,
+ },
+ ),
+ (
+ PlayerId(2),
+ GroundCommand::SelectAction {
+ action: Action::Support,
+ target: Some(PlayerId(1)),
+ problem: None,
+ },
+ ),
+ (
+ PlayerId(1),
+ GroundCommand::SelectAction {
+ action: Action::Ground,
+ target: None,
+ problem: None,
+ },
+ ),
+ ];
+
+ let mut applied = 0;
+ for (seat, command) in picks {
+ applied += apply(state, Actor::Player(seat), &command);
+ }
+ applied += apply(state, Actor::System, &GroundCommand::Reveal);
+ // GR-R05: the GROUND player picks a mode before resolution.
+ applied += apply(
+ state,
+ Actor::Player(PlayerId(1)),
+ &GroundCommand::ChooseGroundMode {
+ mode: GroundMode::Gr,
+ choice: None,
+ },
+ );
+ applied += apply(state, Actor::System, &GroundCommand::Resolve);
+ applied += apply(state, Actor::System, &GroundCommand::EndRound);
+ applied
+}
+
+/// A game ends after Round 5 (GR-R09), so a long run starts a fresh game
+/// rather than idling on a finished one. Setup cost is therefore part of
+/// the measurement, at one setup per five rounds.
+fn run_rounds(rounds: usize) -> usize {
+ let mut applied = 0;
+ let mut state = setup(42);
+ for round in 0..rounds {
+ if state.outcome.is_some() {
+ state = setup(42 + round as u64);
+ }
+ let produced = play_round(&mut state);
+ // A workload whose commands get rejected still "runs", but it
+ // measures nothing. An earlier version of this bench stalled on
+ // the GR-R03 stress gate and reported throughput for rounds that
+ // never happened, so refuse to measure that.
+ assert!(
+ produced == EVENTS_PER_ROUND || produced == FINAL_ROUND_EVENTS,
+ "round {round} produced {produced} events, expected {EVENTS_PER_ROUND} \
+ (or {FINAL_ROUND_EVENTS} on a game's last round)"
+ );
+ applied += produced;
+ }
+ applied
+}
+
+/// Pinned by the `bench_shape` test in the aggregate crate.
+const EVENTS_PER_ROUND: usize = 13;
+/// GR-R09: a game's fifth round emits GameEnded instead of RoundEnded
+/// plus StepAdvanced, so it is one event shorter.
+const FINAL_ROUND_EVENTS: usize = 12;
+/// Mean events per round over a 5-round game, scaled by 5 to stay in
+/// integers: (4 x 13 + 12) = 64.
+const EVENTS_PER_5_ROUNDS: usize = 64;
+
fn bench_synthetic(c: &mut Criterion) {
+ // Events per round is fixed by the workload, so throughput can be
+ // reported in events/second — the AM-6 unit.
+ assert_eq!(play_round(&mut setup(1)), EVENTS_PER_ROUND);
+
let mut group = c.benchmark_group("synthetic-ground-3p");
- // AM-6 headline workload sizes; AM-7 compares 5k vs 100k throughput.
- for &rounds in &[5_000usize, 100_000] {
- group.bench_function(format!("commit-reveal-{rounds}"), |b| {
- b.iter_batched(
- || rounds,
- |n| {
- let mut acc = 0usize;
- for _ in 0..n / 1000 {
- // Scaffold runs 1/1000th scale until T08 wires the
- // real aggregate; the group/ID layout is what T07
- // delivers.
- acc += commit_reveal_round();
- }
- acc
- },
- BatchSize::SmallInput,
- )
+ // AM-6 headline throughput and AM-7 scaling, at the sizes the
+ // boardgame.io harness used so the curves line up.
+ for &rounds in &[5_000usize, 10_000, 20_000, 40_000, 100_000] {
+ group.throughput(Throughput::Elements(
+ (rounds * EVENTS_PER_5_ROUNDS / 5) as u64,
+ ));
+ group.bench_function(format!("rounds-{rounds}"), |b| {
+ b.iter_batched(|| rounds, run_rounds, BatchSize::SmallInput)
});
}
group.finish();
+
+ // AM-7 replay, and the honest analogue of the boardgame.io finding:
+ // a *single* growing event log folded back into state. The round
+ // benches above restart the game every 5 rounds (GR-R09), so their
+ // flat curve is partly by construction — this one is not, because
+ // the log here grows without bound.
+ let mut replay = c.benchmark_group("replay-ground-3p");
+ for &events in &[10_000usize, 100_000] {
+ replay.throughput(Throughput::Elements(events as u64));
+ replay.bench_function(format!("fold-{events}-events"), |b| {
+ // Build one log of `events` events, then measure folding it.
+ let mut source = setup(42);
+ let mut log = Vec::with_capacity(events);
+ while log.len() < events {
+ if source.outcome.is_some() {
+ source = setup(43);
+ }
+ let picks = [
+ (
+ PlayerId(0),
+ GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(1)),
+ problem: None,
+ },
+ ),
+ (
+ PlayerId(1),
+ GroundCommand::SelectAction {
+ action: Action::Support,
+ target: Some(PlayerId(2)),
+ problem: None,
+ },
+ ),
+ ];
+ for (seat, cmd) in picks {
+ if let Ok(produced) = source.validate(Actor::Player(seat), &cmd) {
+ for e in &produced {
+ source.fold(e);
+ log.push(e.clone());
+ }
+ }
+ }
+ if let Ok(produced) = source.validate(Actor::System, &GroundCommand::Reveal) {
+ for e in &produced {
+ source.fold(e);
+ log.push(e.clone());
+ }
+ }
+ if let Ok(produced) = source.validate(Actor::System, &GroundCommand::EndRound) {
+ for e in &produced {
+ source.fold(e);
+ log.push(e.clone());
+ }
+ }
+ }
+ b.iter(|| {
+ let mut state = setup(42);
+ for event in &log {
+ state.fold(event);
+ }
+ state_hash_hex(&state)
+ })
+ });
+ }
+ replay.finish();
+
+ // AM-7: hashing the full aggregate, the per-round determinism cost.
+ let mut hashing = c.benchmark_group("state-hash-ground-3p");
+ hashing.throughput(Throughput::Elements(1));
+ hashing.bench_function("hash-one-state", |b| {
+ let state = setup(42);
+ b.iter(|| state_hash_hex(&state))
+ });
+ hashing.finish();
}
criterion_group!(benches, bench_synthetic);
diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs
index 00d211e..c1d5061 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,
@@ -61,6 +66,54 @@ pub enum Relation {
Rivalry,
}
+/// GR-O05: an unordered player pair, canonically ordered low→high.
+///
+/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON
+/// (GameKernel K7), and JSON object keys must be strings — a tuple key
+/// makes `state_hash` fail on any state that holds a relation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Pair(pub PlayerId, pub PlayerId);
+
+impl Pair {
+ pub fn new(a: PlayerId, b: PlayerId) -> Self {
+ if a <= b {
+ Pair(a, b)
+ } else {
+ Pair(b, a)
+ }
+ }
+
+ pub fn contains(&self, player: PlayerId) -> bool {
+ self.0 == player || self.1 == player
+ }
+}
+
+impl core::fmt::Display for Pair {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{}-{}", self.0 .0, self.1 .0)
+ }
+}
+
+impl Serialize for Pair {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.collect_str(self)
+ }
+}
+
+impl<'de> Deserialize<'de> for Pair {
+ fn deserialize>(deserializer: D) -> Result {
+ let raw = String::deserialize(deserializer)?;
+ let (a, b) = raw
+ .split_once('-')
+ .ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?;
+ let parse = |s: &str| {
+ s.parse::()
+ .map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
+ };
+ Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
+ }
+}
+
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
/// collections only (GameKernel K6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -69,12 +122,1788 @@ pub struct GroundState {
pub lead: PlayerId,
pub players: BTreeMap,
/// Keyed by (low, high) player pair.
- pub relations: BTreeMap<(PlayerId, PlayerId), Relation>,
+ pub relations: BTreeMap,
pub problems: BTreeMap,
pub solution_deck: Vec,
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-R05: GROUND modes chosen after Reveal, before Resolve.
+ pub ground_modes: BTreeMap,
+ /// GR-A11/A12: the sub-choice accompanying an OU or ND mode.
+ pub ground_choices: BTreeMap,
+ /// GR-L02/A05: a Support target's response, keyed by target.
+ pub support_responses: BTreeMap,
+ /// GR-D03/D04: the mandatory target a DARVO stage needs this round.
+ pub darvo_targets: BTreeMap,
+ /// GR-E02..E04: which scoring mode this game uses.
+ pub mode: ScoringMode,
+ /// GR-R09: set once the game has ended and scoring has run.
+ pub outcome: Option,
+ /// GR-S04/U4: retained so a deck reshuffle stays a pure function of
+ /// state, keeping `validate` deterministic without holding RNG state.
+ pub seed: u64,
+}
+
+/// GR-E02..E04: the three scoring modes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ScoringMode {
+ /// GR-E02, co-op: one shared score against the threshold.
+ SharedGround,
+ /// GR-E03, semi-co-op: personal scores once the group qualifies.
+ CommonProblem,
+ /// GR-E04: Bond networks score together.
+ BondedCoalitions,
+}
+
+/// GR-E01..E04: the final scoring result.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Outcome {
+ /// GR-E01: summed printed values of all claimed Problems.
+ pub total: u32,
+ pub threshold: u32,
+ pub group_success: bool,
+ /// GR-E03: claimed value −1 per Blame held.
+ pub personal: BTreeMap,
+ /// GR-E04: each Bond-connected group and its combined score.
+ pub coalitions: Vec,
+ /// GR-E02: only meaningful in SHARED GROUND.
+ pub mastery: Option,
+ pub winners: Vec,
+}
+
+/// GR-E04: one Bond-connected group. Unbonded players are solo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Coalition {
+ pub members: Vec,
+ pub score: i32,
+}
+
+/// GR-D03/D04: what a DARVO stage acts on this round. DENY names a
+/// Problem, ATTACK names a player; REVERSE takes its target from the
+/// Focus token placed by the ATTACK stage.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct DarvoTarget {
+ pub problem: Option,
+ pub player: Option,
+}
+
+/// GR-A10..A12: the three GROUND modes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum GroundMode {
+ /// Ground & Restate (GR-A10).
+ Gr,
+ /// Observe & Uphold (GR-A11).
+ Ou,
+ /// Name & Decide (GR-A12).
+ Nd,
+}
+
+/// GR-A11/A12: the sub-choice a GROUND—OU or GROUND—ND player makes
+/// alongside the mode. GROUND—GR takes none.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "choice")]
+pub enum GroundChoice {
+ /// GR-A11: restore one Denied Problem.
+ RestoreProblem { problem: u32 },
+ /// GR-A11: cancel one Attack targeting this player this round.
+ CancelAttack { attacker: PlayerId },
+ /// GR-A11: protect one face-up Problem from Deny this round.
+ ProtectProblem { problem: u32 },
+ /// GR-A12: remove one Blame token from this player.
+ RemoveBlame { owner: PlayerId },
+ /// GR-A12: break one relation involving this player.
+ BreakRelation { with: PlayerId },
+ /// GR-A12: reject one Reverse targeting this player this round.
+ RejectReverse,
+}
+
+impl GroundChoice {
+ /// GR-A11/A12: each choice belongs to exactly one mode.
+ fn mode(self) -> GroundMode {
+ match self {
+ GroundChoice::RestoreProblem { .. }
+ | GroundChoice::CancelAttack { .. }
+ | GroundChoice::ProtectProblem { .. } => GroundMode::Ou,
+ GroundChoice::RemoveBlame { .. }
+ | GroundChoice::BreakRelation { .. }
+ | GroundChoice::RejectReverse => GroundMode::Nd,
+ }
+ }
+
+ fn parse(raw: &str, arg: Option) -> Result {
+ let need = |what: &str| {
+ arg.ok_or_else(|| format!("GROUND choice {raw:?} needs a {what} argument"))
+ };
+ match raw {
+ "restore_problem" => Ok(GroundChoice::RestoreProblem {
+ problem: need("problem")? as u32,
+ }),
+ "cancel_attack" => Ok(GroundChoice::CancelAttack {
+ attacker: PlayerId(need("seat")? as u8),
+ }),
+ "protect_problem" => Ok(GroundChoice::ProtectProblem {
+ problem: need("problem")? as u32,
+ }),
+ "remove_blame" => Ok(GroundChoice::RemoveBlame {
+ owner: PlayerId(need("seat")? as u8),
+ }),
+ "break_relation" => Ok(GroundChoice::BreakRelation {
+ with: PlayerId(need("seat")? as u8),
+ }),
+ "reject_reverse" => Ok(GroundChoice::RejectReverse),
+ other => Err(format!("unknown GROUND choice {other:?}")),
+ }
+ }
+}
+
+/// GR-L02/A05: how a Support target responds, chosen after Reveal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SupportResponse {
+ /// GR-L02: accept a Bond where no relation exists.
+ AcceptBond,
+ /// GR-L02: decline it; the Stress effect still applies.
+ DeclineBond,
+ /// GR-A05: turn an existing Rivalry into a Bond.
+ FlipToBond,
+ /// GR-A05: break the existing Rivalry.
+ BreakRivalry,
+}
+
+impl SupportResponse {
+ fn parse(raw: &str) -> Result {
+ match raw {
+ "accept_bond" => Ok(SupportResponse::AcceptBond),
+ "decline_bond" => Ok(SupportResponse::DeclineBond),
+ "flip_to_bond" => Ok(SupportResponse::FlipToBond),
+ "break_rivalry" => Ok(SupportResponse::BreakRivalry),
+ other => Err(format!("unknown support response {other:?}")),
+ }
+ }
+}
+
+impl GroundMode {
+ fn parse(raw: &str) -> Result {
+ match raw {
+ "GR" => Ok(GroundMode::Gr),
+ "OU" => Ok(GroundMode::Ou),
+ "ND" => Ok(GroundMode::Nd),
+ other => Err(format!(
+ "unknown GROUND mode {other:?} (expected GR, OU or ND)"
+ )),
+ }
+ }
+}
+
+/// 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,
+ /// GR-R05: a player who revealed GROUND chooses its mode after
+ /// seeing all revealed Actions.
+ ChooseGroundMode {
+ mode: GroundMode,
+ choice: Option,
+ },
+ /// GR-L02/A05: respond to a Support aimed at this player.
+ RespondToSupport { response: SupportResponse },
+ /// GR-D03/D04: name the mandatory target of this round's stage.
+ ChooseDarvoTarget { target: DarvoTarget },
+ /// GR-R04: reveal all selections simultaneously. System-driven.
+ Reveal,
+ /// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
+ /// first. System-driven.
+ Resolve,
+ /// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the
+ /// Round marker. System-driven.
+ EndRound,
+}
+
+/// 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,
+ },
+ /// GR-R04.
+ Revealed,
+ /// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial.
+ StressSet {
+ player: PlayerId,
+ stress: u8,
+ },
+ /// GR-F04.
+ FreedomReadied {
+ player: PlayerId,
+ },
+ /// GR-L02/L03: endpoints ordered low→high (GR-O05).
+ RelationFormed {
+ pair: Pair,
+ relation: Relation,
+ },
+ /// GR-L04.
+ RelationBroken {
+ pair: Pair,
+ },
+ /// GR-A09: Protection absorbed an Attack.
+ AttackCancelled {
+ attacker: PlayerId,
+ target: PlayerId,
+ },
+ /// GR-R05/A11/A12.
+ GroundModeChosen {
+ player: PlayerId,
+ mode: GroundMode,
+ choice: Option,
+ },
+ /// GR-L02/A05.
+ SupportAnswered {
+ player: PlayerId,
+ response: SupportResponse,
+ },
+ /// GR-A11: a Denied Problem was restored face up.
+ ProblemRestored {
+ problem: u32,
+ },
+ /// GR-A11: a face-up Problem is protected from Deny this round.
+ ProblemProtected {
+ problem: u32,
+ },
+ /// GR-A12/T02: a Blame token was removed and returned to its owner.
+ BlameRemoved {
+ player: PlayerId,
+ owner: PlayerId,
+ },
+ /// GR-A01: a hidden Problem was turned face up.
+ ProblemRevealed {
+ problem: u32,
+ },
+ /// GR-A01: one Solution drawn from the deck.
+ SolutionDrawn {
+ player: PlayerId,
+ card: SolutionCard,
+ },
+ /// GR-A02: a matching Solution was spent to claim a Problem.
+ SolutionDiscarded {
+ player: PlayerId,
+ card: SolutionCard,
+ },
+ /// GR-A02.
+ ProblemClaimed {
+ problem: u32,
+ by: PlayerId,
+ },
+ /// GR-A01 under the U4 default: the discard was reshuffled into the
+ /// deck. The resulting order travels in the event, so `fold` stays
+ /// deterministic without replaying the RNG.
+ DeckReshuffled {
+ order: Vec,
+ },
+ /// GR-D01: Stress 5 at End with the marker OFF.
+ DarvoTriggered {
+ player: PlayerId,
+ },
+ /// GR-D03/D04.
+ DarvoTargetChosen {
+ player: PlayerId,
+ target: DarvoTarget,
+ },
+ /// GR-D03: a Problem was turned face down and Denied.
+ ProblemDenied {
+ problem: u32,
+ },
+ /// GR-D04/T03: the sequence owner's Focus token was placed.
+ FocusPlaced {
+ owner: PlayerId,
+ target: PlayerId,
+ },
+ /// GR-D05/T03: Focus flipped to Blame in front of the target.
+ FocusFlippedToBlame {
+ owner: PlayerId,
+ target: PlayerId,
+ },
+ /// GR-D05/T01.
+ ProtectionGained {
+ player: PlayerId,
+ },
+ /// GR-D02: the sequence moved to its next stage.
+ DarvoAdvanced {
+ player: PlayerId,
+ stage: DarvoStage,
+ },
+ /// GR-D05/D06/D07: the sequence ended and the marker is OFF again.
+ DarvoEnded {
+ player: PlayerId,
+ },
+ /// GR-R09/E01..E04: the game ended and scoring ran.
+ GameEnded {
+ outcome: Outcome,
+ },
+ /// GR-R01: the round advanced to `step`.
+ StepAdvanced {
+ step: RoundStep,
+ },
+ /// GR-R08: Lead rotated and the Round marker advanced.
+ RoundEnded {
+ round: u8,
+ next_lead: 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 relation_between(&self, a: PlayerId, b: PlayerId) -> Option {
+ self.relations.get(&Pair::new(a, b)).copied()
+ }
+
+ /// GR-L01: two relation slots per player.
+ fn has_free_slot(&self, player: PlayerId) -> bool {
+ let used = self
+ .relations
+ .keys()
+ .filter(|pair| pair.contains(player))
+ .count();
+ used < 2
+ }
+
+ /// GR-R07: resolution order starts at the Lead and continues
+ /// clockwise (ascending seat, wrapping).
+ fn seat_order(&self) -> Vec {
+ let seats: Vec = self.players.keys().copied().collect();
+ let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
+ seats[start..]
+ .iter()
+ .chain(&seats[..start])
+ .copied()
+ .collect()
+ }
+
+ /// GR-F01 with the U2 default: clamp on every application, so no
+ /// intermediate value escapes 0–5.
+ fn stress_after(&self, player: PlayerId, delta: i16) -> u8 {
+ let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress));
+ current.saturating_add(delta).clamp(0, 5) as u8
+ }
+
+ 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> {
+ // GR-R04/R06/R08 are runtime-driven, not player-issued.
+ match command {
+ GroundCommand::Reveal => {
+ if self.step != RoundStep::Select || actor != Actor::System {
+ return Err(Rejection::NotAllowedNow);
+ }
+ if self.selections.len() != self.players.len() {
+ return Err(Rejection::Game {
+ code: "select-incomplete".into(),
+ detail: "GR-R04: every player must select before Reveal".into(),
+ });
+ }
+ return Ok(vec![
+ GroundEvent::Revealed,
+ GroundEvent::StepAdvanced {
+ step: RoundStep::Reveal,
+ },
+ ]);
+ }
+ GroundCommand::Resolve => {
+ if self.step != RoundStep::Reveal || actor != Actor::System {
+ return Err(Rejection::NotAllowedNow);
+ }
+ // GR-R05: modes are chosen before resolution begins.
+ let pending: Vec = self
+ .selections
+ .iter()
+ .filter(|(seat, sel)| {
+ sel.action == Action::Ground && !self.ground_modes.contains_key(seat)
+ })
+ .map(|(seat, _)| *seat)
+ .collect();
+ if !pending.is_empty() {
+ return Err(Rejection::Game {
+ code: "ground-mode-pending".into(),
+ detail: format!("GR-R05: no GROUND mode chosen for {pending:?}"),
+ });
+ }
+ return Ok(self.resolution_events());
+ }
+ GroundCommand::EndRound => {
+ if self.step != RoundStep::Resolve || actor != Actor::System {
+ return Err(Rejection::NotAllowedNow);
+ }
+ return Ok(self.end_round_events());
+ }
+ _ => {}
+ }
+
+ 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 }])
+ }
+ // GR-R05: only after Reveal, only for a player who revealed
+ // GROUND, once.
+ GroundCommand::ChooseGroundMode { mode, choice } => {
+ if self.step != RoundStep::Reveal {
+ return Err(Rejection::NotAllowedNow);
+ }
+ let revealed_ground = self
+ .selections
+ .get(&id)
+ .is_some_and(|s| s.action == Action::Ground);
+ if !revealed_ground {
+ return Err(Rejection::Game {
+ code: "no-ground-revealed".into(),
+ detail: "GR-R05: only a player who revealed GROUND chooses a mode".into(),
+ });
+ }
+ if self.ground_modes.contains_key(&id) {
+ return Err(Rejection::DuplicateCommand);
+ }
+ let _ = player;
+ // GR-A10..A12: GR takes no sub-choice; OU and ND each
+ // require one drawn from their own list.
+ match (mode, choice) {
+ (GroundMode::Gr, None) => {}
+ (GroundMode::Gr, Some(_)) => {
+ return Err(Rejection::Game {
+ code: "unexpected-choice".into(),
+ detail: "GR-A10: GROUND—GR takes no sub-choice".into(),
+ })
+ }
+ (wanted, Some(choice)) if choice.mode() == *wanted => {}
+ (wanted, _) => {
+ return Err(Rejection::Game {
+ code: "bad-choice".into(),
+ detail: format!("GR-A11/A12: {wanted:?} needs one of its own choices"),
+ })
+ }
+ }
+ self.check_ground_choice(id, *choice)?;
+ Ok(vec![GroundEvent::GroundModeChosen {
+ player: id,
+ mode: *mode,
+ choice: *choice,
+ }])
+ }
+ // GR-L02/A05: only the target of a revealed Support answers,
+ // once, and only with a response its relation admits.
+ GroundCommand::RespondToSupport { response } => {
+ if self.step != RoundStep::Reveal {
+ return Err(Rejection::NotAllowedNow);
+ }
+ let supporter = self.selections.iter().find(|(seat, sel)| {
+ sel.action == Action::Support && sel.target == Some(id) && **seat != id
+ });
+ let Some((supporter, _)) = supporter else {
+ return Err(Rejection::Game {
+ code: "no-support-received".into(),
+ detail: "GR-L02: no revealed Support targets this player".into(),
+ });
+ };
+ if self.support_responses.contains_key(&id) {
+ return Err(Rejection::DuplicateCommand);
+ }
+ let admitted = match self.relation_between(*supporter, id) {
+ // GR-L02: no relation — the target may accept a Bond.
+ None => matches!(
+ response,
+ SupportResponse::AcceptBond | SupportResponse::DeclineBond
+ ),
+ // GR-A05: through a Rivalry — flip it or break it.
+ Some(Relation::Rivalry) => matches!(
+ response,
+ SupportResponse::FlipToBond | SupportResponse::BreakRivalry
+ ),
+ // GR-A04: through a Bond — nothing to answer.
+ Some(Relation::Bond) => false,
+ };
+ if !admitted {
+ return Err(Rejection::Game {
+ code: "bad-response".into(),
+ detail: format!("GR-L02/A05: {response:?} is not available here"),
+ });
+ }
+ Ok(vec![GroundEvent::SupportAnswered {
+ player: id,
+ response: *response,
+ }])
+ }
+ // GR-D03/D04: only a player with a live sequence, after
+ // Reveal, once per round.
+ GroundCommand::ChooseDarvoTarget { target } => {
+ if self.step != RoundStep::Reveal {
+ return Err(Rejection::NotAllowedNow);
+ }
+ if player.darvo == DarvoStage::Off {
+ return Err(Rejection::Game {
+ code: "no-darvo-sequence".into(),
+ detail: "GR-D02: this player has no live DARVO sequence".into(),
+ });
+ }
+ if self.darvo_targets.contains_key(&id) {
+ return Err(Rejection::DuplicateCommand);
+ }
+ match player.darvo {
+ // GR-D03: a face-up, unsolved, unprotected Problem.
+ DarvoStage::Deny => {
+ let problem = target.problem.ok_or(Rejection::Game {
+ code: "bad-darvo-target".into(),
+ detail: "GR-D03: DENY names a Problem".into(),
+ })?;
+ let eligible = self.problems.get(&problem).is_some_and(|p| {
+ p.face_up
+ && !p.denied
+ && p.claimed_by.is_none()
+ && !p.protected_this_round
+ });
+ if !eligible {
+ return Err(Rejection::Game {
+ code: "bad-darvo-target".into(),
+ detail: format!(
+ "GR-D03: Problem {problem} is not face-up, unsolved and unprotected"
+ ),
+ });
+ }
+ }
+ // GR-D04: an extra Attack against another player.
+ DarvoStage::Attack => {
+ let other = target.player.ok_or(Rejection::Game {
+ code: "bad-darvo-target".into(),
+ detail: "GR-D04: ATTACK names a player".into(),
+ })?;
+ if other == id || !self.players.contains_key(&other) {
+ return Err(Rejection::Game {
+ code: "bad-darvo-target".into(),
+ detail: "GR-D04: ATTACK targets another player".into(),
+ });
+ }
+ }
+ // GR-D05: REVERSE uses the placed Focus token.
+ DarvoStage::Reverse | DarvoStage::Off => {}
+ }
+ Ok(vec![GroundEvent::DarvoTargetChosen {
+ player: id,
+ target: *target,
+ }])
+ }
+ GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
+ unreachable!("system commands are handled above")
+ }
+ }
+ }
+
+ 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;
+ }
+ }
+ GroundEvent::Revealed => {}
+ GroundEvent::StressSet { player, stress } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.stress = *stress;
+ }
+ }
+ GroundEvent::FreedomReadied { player } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.freedom_ready = true;
+ }
+ }
+ GroundEvent::RelationFormed { pair, relation } => {
+ self.relations.insert(*pair, *relation);
+ }
+ GroundEvent::RelationBroken { pair } => {
+ self.relations.remove(pair);
+ }
+ GroundEvent::AttackCancelled { target, .. } => {
+ if let Some(state) = self.players.get_mut(target) {
+ state.protection = state.protection.saturating_sub(1);
+ }
+ }
+ GroundEvent::GroundModeChosen {
+ player,
+ mode,
+ choice,
+ } => {
+ self.ground_modes.insert(*player, *mode);
+ if let Some(choice) = choice {
+ self.ground_choices.insert(*player, *choice);
+ }
+ }
+ GroundEvent::SupportAnswered { player, response } => {
+ self.support_responses.insert(*player, *response);
+ }
+ GroundEvent::ProblemRestored { problem } => {
+ if let Some(state) = self.problems.get_mut(problem) {
+ state.denied = false;
+ state.face_up = true;
+ }
+ }
+ GroundEvent::ProblemProtected { problem } => {
+ if let Some(state) = self.problems.get_mut(problem) {
+ state.protected_this_round = true;
+ }
+ }
+ GroundEvent::BlameRemoved { player, owner } => {
+ if let Some(state) = self.players.get_mut(player) {
+ if let Some(pos) = state.blame_from.iter().position(|o| o == owner) {
+ state.blame_from.remove(pos);
+ }
+ }
+ }
+ GroundEvent::ProblemRevealed { problem } => {
+ if let Some(state) = self.problems.get_mut(problem) {
+ state.face_up = true;
+ }
+ }
+ GroundEvent::SolutionDrawn { player, card } => {
+ // The deck draws from its end, matching the GR-S02 deal.
+ self.solution_deck.pop();
+ if let Some(state) = self.players.get_mut(player) {
+ state.hand.push(*card);
+ }
+ }
+ GroundEvent::SolutionDiscarded { player, card } => {
+ if let Some(state) = self.players.get_mut(player) {
+ if let Some(pos) = state.hand.iter().position(|c| c == card) {
+ state.hand.remove(pos);
+ }
+ }
+ self.solution_discard.push(*card);
+ }
+ GroundEvent::ProblemClaimed { problem, by } => {
+ if let Some(state) = self.problems.get_mut(problem) {
+ state.claimed_by = Some(*by);
+ }
+ }
+ GroundEvent::DeckReshuffled { order } => {
+ self.solution_deck = order.clone();
+ self.solution_discard.clear();
+ }
+ GroundEvent::DarvoTriggered { player } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.darvo = DarvoStage::Deny;
+ }
+ }
+ GroundEvent::DarvoTargetChosen { player, target } => {
+ self.darvo_targets.insert(*player, *target);
+ }
+ GroundEvent::ProblemDenied { problem } => {
+ if let Some(state) = self.problems.get_mut(problem) {
+ state.denied = true;
+ state.face_up = false;
+ }
+ }
+ GroundEvent::FocusPlaced { owner, target } => {
+ self.focus.insert(*owner, *target);
+ }
+ GroundEvent::FocusFlippedToBlame { owner, target } => {
+ self.focus.remove(owner);
+ if let Some(state) = self.players.get_mut(target) {
+ state.blame_from.push(*owner);
+ }
+ }
+ GroundEvent::ProtectionGained { player } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.protection = state.protection.saturating_add(1);
+ }
+ }
+ GroundEvent::DarvoAdvanced { player, stage } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.darvo = *stage;
+ }
+ }
+ GroundEvent::DarvoEnded { player } => {
+ if let Some(state) = self.players.get_mut(player) {
+ state.darvo = DarvoStage::Off;
+ }
+ // GR-D06: an unresolved Focus token comes back.
+ self.focus.remove(player);
+ }
+ GroundEvent::GameEnded { outcome } => {
+ self.outcome = Some(outcome.clone());
+ self.step = RoundStep::End;
+ }
+ GroundEvent::StepAdvanced { step } => {
+ self.step = *step;
+ }
+ GroundEvent::RoundEnded { round, next_lead } => {
+ self.round = *round;
+ self.lead = *next_lead;
+ self.selections.clear();
+ self.ground_modes.clear();
+ self.ground_choices.clear();
+ self.support_responses.clear();
+ self.darvo_targets.clear();
+ for state in self.players.values_mut() {
+ // GR-R03: the gate lift lasts one Select step only.
+ state.freedom_gate_lifted = false;
+ }
+ for problem in self.problems.values_mut() {
+ // GR-A11: OU protection lasts one round.
+ problem.protected_this_round = false;
+ }
+ }
+ }
+ }
+}
+
+impl GroundState {
+ /// GR-R06/R07: resolve in fixed step order, Lead first within a step.
+ ///
+ /// Step 3 (active DARVO stages) is not yet implemented — the DARVO
+ /// machine lands with GR-D02..D07 and no scenario claims coverage of
+ /// it. GROUND—OU and GROUND—ND (GR-A11/A12) each offer a three-way
+ /// choice that needs its own command and are likewise pending.
+ fn resolution_events(&self) -> Vec {
+ let mut events = Vec::new();
+ // A working copy so slot counts and Stress reflect earlier
+ // effects within the same resolution, per GR-R07 ordering.
+ let mut work = self.clone();
+
+ // Relations as they stood before this round's Support step
+ // (GR-L05): a Bond formed now is not "pre-existing".
+ let pre_existing = self.relations.clone();
+
+ // GR-A11: Attacks cancelled by a GROUND—OU choice this round.
+ let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> =
+ std::collections::BTreeSet::new();
+
+ // Step 1 — GROUND (GR-A10..A12).
+ for actor in self.seat_order() {
+ if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) {
+ continue;
+ }
+ match self.ground_modes.get(&actor) {
+ // GR-A10: Ground & Restate — self −2 Stress, Freedom READY.
+ Some(GroundMode::Gr) => {
+ let stress = work.stress_after(actor, -2);
+ events.push(GroundEvent::StressSet {
+ player: actor,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ if !work.players[&actor].freedom_ready {
+ events.push(GroundEvent::FreedomReadied { player: actor });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ // GR-A11: Observe & Uphold.
+ Some(GroundMode::Ou) => match work.ground_choices.get(&actor).copied() {
+ Some(GroundChoice::RestoreProblem { problem }) => {
+ events.push(GroundEvent::ProblemRestored { problem });
+ work.fold(events.last().expect("just pushed"));
+ }
+ Some(GroundChoice::ProtectProblem { problem }) => {
+ events.push(GroundEvent::ProblemProtected { problem });
+ work.fold(events.last().expect("just pushed"));
+ }
+ // Consumed in the Attack step below.
+ Some(GroundChoice::CancelAttack { attacker }) => {
+ ou_cancels.insert((attacker, actor));
+ }
+ _ => {}
+ },
+ // GR-A12: Name & Decide.
+ Some(GroundMode::Nd) => match work.ground_choices.get(&actor).copied() {
+ Some(GroundChoice::RemoveBlame { owner }) => {
+ events.push(GroundEvent::BlameRemoved {
+ player: actor,
+ owner,
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ Some(GroundChoice::BreakRelation { with }) => {
+ events.push(GroundEvent::RelationBroken {
+ pair: Pair::new(actor, with),
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ // GR-D05: consumed by the Reverse stage.
+ _ => {}
+ },
+ None => {}
+ }
+ }
+
+ // Step 2 — Support (GR-A03/A04/A05).
+ for actor in self.seat_order() {
+ let Some(selection) = self.selections.get(&actor) else {
+ continue;
+ };
+ if selection.action != Action::Support {
+ continue;
+ }
+ let Some(target) = selection.target else {
+ continue;
+ };
+ match pre_existing.get(&Pair::new(actor, target)).copied() {
+ // GR-A04: Support through an existing Bond.
+ Some(Relation::Bond) => {
+ let stress = work.stress_after(target, -2);
+ events.push(GroundEvent::StressSet {
+ player: target,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ // GR-F04: a Bond Support readies the target's token.
+ if !work.players[&target].freedom_ready {
+ events.push(GroundEvent::FreedomReadied { player: target });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ // GR-A05: Support through a Rivalry — −1 Stress, then
+ // the target flips it to a Bond or breaks it.
+ Some(Relation::Rivalry) => {
+ let stress = work.stress_after(target, -1);
+ events.push(GroundEvent::StressSet {
+ player: target,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ let pair = Pair::new(actor, target);
+ match work.support_responses.get(&target).copied() {
+ Some(SupportResponse::FlipToBond) => {
+ events.push(GroundEvent::RelationFormed {
+ pair,
+ relation: Relation::Bond,
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ Some(SupportResponse::BreakRivalry) => {
+ events.push(GroundEvent::RelationBroken { pair });
+ work.fold(events.last().expect("just pushed"));
+ }
+ // No answer: the Rivalry stands.
+ _ => {}
+ }
+ }
+ // GR-A03/L02: no relation — −1 Stress, and a Bond forms
+ // only if the target accepts and both have a free slot.
+ None => {
+ let stress = work.stress_after(target, -1);
+ events.push(GroundEvent::StressSet {
+ player: target,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ let accepted = work.support_responses.get(&target).copied()
+ == Some(SupportResponse::AcceptBond);
+ if accepted && work.has_free_slot(actor) && work.has_free_slot(target) {
+ events.push(GroundEvent::RelationFormed {
+ pair: Pair::new(actor, target),
+ relation: Relation::Bond,
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ }
+ }
+
+ // Step 3 — active DARVO stages (GR-D02..D07).
+ for owner in self.seat_order() {
+ let stage = work.players[&owner].darvo;
+ if stage == DarvoStage::Off {
+ continue;
+ }
+
+ // GR-D06 + GR-A04: a Support through a Bond that existed
+ // before this round's Support step cancels the current stage
+ // and ends the sequence (GR-L05).
+ let bond_support = self.selections.iter().any(|(seat, sel)| {
+ sel.action == Action::Support
+ && sel.target == Some(owner)
+ && pre_existing.get(&Pair::new(*seat, owner)) == Some(&Relation::Bond)
+ });
+ if bond_support {
+ events.push(GroundEvent::DarvoEnded { player: owner });
+ work.fold(events.last().expect("just pushed"));
+ continue;
+ }
+
+ match stage {
+ // GR-D03: turn one eligible Problem face down and Deny
+ // it. Under the U3 default, no legal target is a no-op
+ // and the sequence still advances.
+ DarvoStage::Deny => {
+ if let Some(problem) = work.darvo_targets.get(&owner).and_then(|t| t.problem) {
+ let eligible = work.problems.get(&problem).is_some_and(|p| {
+ p.face_up
+ && !p.denied
+ && p.claimed_by.is_none()
+ && !p.protected_this_round
+ });
+ if eligible {
+ events.push(GroundEvent::ProblemDenied { problem });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ }
+ // GR-D04: one extra Attack under the normal relation
+ // rules, then place the Focus token beside the target —
+ // even if the Attack was cancelled.
+ DarvoStage::Attack => {
+ if let Some(target) = work.darvo_targets.get(&owner).and_then(|t| t.player) {
+ work.resolve_attack(owner, target, &ou_cancels, &mut events);
+ events.push(GroundEvent::FocusPlaced { owner, target });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ // GR-D05: targets the Focus holder.
+ DarvoStage::Reverse => {
+ if let Some(target) = work.focus.get(&owner).copied() {
+ // GR-A12: the target's GROUND—ND may reject it.
+ let rejected =
+ work.ground_choices.get(&target) == Some(&GroundChoice::RejectReverse);
+ if !rejected {
+ events.push(GroundEvent::FocusFlippedToBlame { owner, target });
+ work.fold(events.last().expect("just pushed"));
+ let stress = work.stress_after(target, 1);
+ events.push(GroundEvent::StressSet {
+ player: target,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ events.push(GroundEvent::ProtectionGained { player: owner });
+ work.fold(events.last().expect("just pushed"));
+ }
+ // U5: rejected or not, the owner still takes −2
+ // and the sequence ends.
+ let stress = work.stress_after(owner, -2);
+ events.push(GroundEvent::StressSet {
+ player: owner,
+ stress,
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ DarvoStage::Off => {}
+ }
+
+ // GR-A10 + GR-D06: GROUND—GR ends the sequence after the
+ // current stage resolves. GR-D05: REVERSE ends it anyway.
+ let ground_gr = work.ground_modes.get(&owner) == Some(&GroundMode::Gr);
+ if ground_gr || stage == DarvoStage::Reverse {
+ events.push(GroundEvent::DarvoEnded { player: owner });
+ work.fold(events.last().expect("just pushed"));
+ } else {
+ // GR-D02: one stage per consecutive round.
+ let next = match stage {
+ DarvoStage::Deny => DarvoStage::Attack,
+ _ => DarvoStage::Reverse,
+ };
+ events.push(GroundEvent::DarvoAdvanced {
+ player: owner,
+ stage: next,
+ });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+
+ // Step 5 — Attack (GR-A06..A09).
+ for actor in self.seat_order() {
+ let Some(selection) = self.selections.get(&actor) else {
+ continue;
+ };
+ if selection.action != Action::Attack {
+ continue;
+ }
+ let Some(target) = selection.target else {
+ continue;
+ };
+
+ work.resolve_attack(actor, target, &ou_cancels, &mut events);
+ }
+
+ // Step 4 — INVESTIGATE (GR-A01).
+ for actor in self.seat_order() {
+ let Some(selection) = self.selections.get(&actor) else {
+ continue;
+ };
+ if selection.action != Action::Investigate {
+ continue;
+ }
+ // Reveal the chosen Problem if it is still hidden and not
+ // Denied; otherwise the draw happens on its own.
+ if let Some(problem) = selection.problem {
+ let eligible = work
+ .problems
+ .get(&problem)
+ .is_some_and(|p| !p.face_up && !p.denied);
+ if eligible {
+ events.push(GroundEvent::ProblemRevealed { problem });
+ work.fold(events.last().expect("just pushed"));
+ }
+ }
+ work.draw_solution(actor, &mut events);
+ }
+
+ // Step 6 — SOLVE (GR-A02).
+ for actor in self.seat_order() {
+ let Some(selection) = self.selections.get(&actor) else {
+ continue;
+ };
+ if selection.action != Action::Solve {
+ continue;
+ }
+ let Some(problem) = selection.problem else {
+ continue;
+ };
+ let Some(target) = work.problems.get(&problem) else {
+ continue;
+ };
+ // GR-A02: an earlier resolver this round already claimed it,
+ // so no Solution is spent and nothing happens.
+ if target.claimed_by.is_some() || target.denied || !target.face_up {
+ continue;
+ }
+ let required = target.suit;
+ let Some(card) = work.players[&actor]
+ .hand
+ .iter()
+ .find(|c| c.suit == required)
+ .copied()
+ else {
+ continue;
+ };
+ events.push(GroundEvent::SolutionDiscarded {
+ player: actor,
+ card,
+ });
+ work.fold(events.last().expect("just pushed"));
+ events.push(GroundEvent::ProblemClaimed { problem, by: actor });
+ work.fold(events.last().expect("just pushed"));
+ }
+
+ events.push(GroundEvent::StepAdvanced {
+ step: RoundStep::Resolve,
+ });
+ events
+ }
+
+ /// GR-A06..A09: one Attack under the normal relation rules. Shared
+ /// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's
+ /// extra Attack (GR-D04).
+ fn resolve_attack(
+ &mut self,
+ attacker: PlayerId,
+ target: PlayerId,
+ ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>,
+ events: &mut Vec,
+ ) {
+ // GR-A09 under the U8 default: a GROUND—OU cancellation is
+ // chosen at step 1 and applies first, so Protection is only
+ // consumed when it is what actually cancels.
+ if ou_cancels.contains(&(attacker, target)) {
+ return;
+ }
+ // GR-A09/T01: Protection absorbs the Attack entirely.
+ if self.players[&target].protection > 0 {
+ events.push(GroundEvent::AttackCancelled { attacker, target });
+ self.fold(events.last().expect("just pushed"));
+ return;
+ }
+
+ let pair = Pair::new(attacker, target);
+ let (delta, after) = match self.relation_between(attacker, target) {
+ // GR-A07: through a Bond — +2 and the Bond flips.
+ Some(Relation::Bond) => (
+ 2,
+ Some(GroundEvent::RelationFormed {
+ pair,
+ relation: Relation::Rivalry,
+ }),
+ ),
+ // GR-A08: through a Rivalry — +2 and it breaks.
+ Some(Relation::Rivalry) => (2, Some(GroundEvent::RelationBroken { pair })),
+ // GR-A06: no relation — +1, and a Rivalry forms without
+ // consent if both endpoints have a slot (GR-L01/L03).
+ None => {
+ let forms = self.has_free_slot(attacker) && self.has_free_slot(target);
+ (
+ 1,
+ forms.then_some(GroundEvent::RelationFormed {
+ pair,
+ relation: Relation::Rivalry,
+ }),
+ )
+ }
+ };
+
+ let stress = self.stress_after(target, delta);
+ events.push(GroundEvent::StressSet {
+ player: target,
+ stress,
+ });
+ self.fold(events.last().expect("just pushed"));
+ if let Some(event) = after {
+ events.push(event);
+ self.fold(events.last().expect("just pushed"));
+ }
+ }
+
+ /// GR-A01: draw one Solution, reshuffling the discard first if the
+ /// deck is empty (the U4 default). The reshuffled order travels in
+ /// the event so replay never re-derives it.
+ fn draw_solution(&mut self, player: PlayerId, events: &mut Vec) {
+ if self.solution_deck.is_empty() {
+ if self.solution_discard.is_empty() {
+ return;
+ }
+ let mut order = self.solution_discard.clone();
+ // Derived from the game seed and round, so the reshuffle is
+ // a pure function of state (GameKernel K5).
+ let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round)));
+ rng.shuffle(&mut order);
+ events.push(GroundEvent::DeckReshuffled { order });
+ self.fold(events.last().expect("just pushed"));
+ }
+ if let Some(card) = self.solution_deck.last().copied() {
+ events.push(GroundEvent::SolutionDrawn { player, card });
+ self.fold(events.last().expect("just pushed"));
+ }
+ }
+
+ /// GR-R08: Stress is already clamped on every application (U2), so
+ /// End only triggers DARVO, rotates the Lead, and advances the Round.
+ fn end_round_events(&self) -> Vec {
+ let mut events = Vec::new();
+
+ // GR-D01: Stress 5 with the marker OFF starts a sequence. In
+ // Lead order, so two simultaneous triggers are ordered (U9).
+ for seat in self.seat_order() {
+ let player = &self.players[&seat];
+ if player.stress == 5 && player.darvo == DarvoStage::Off {
+ events.push(GroundEvent::DarvoTriggered { player: seat });
+ }
+ }
+
+ let seats: Vec = self.players.keys().copied().collect();
+ let next_lead = seats
+ .iter()
+ .position(|s| *s == self.lead)
+ .map_or(self.lead, |i| seats[(i + 1) % seats.len()]);
+
+ // GR-R09: after Round 5's End the game ends and scoring applies.
+ if self.round >= 5 {
+ events.push(GroundEvent::GameEnded {
+ outcome: self.score(),
+ });
+ return events;
+ }
+
+ events.push(GroundEvent::RoundEnded {
+ round: self.round + 1,
+ next_lead,
+ });
+ events.push(GroundEvent::StepAdvanced {
+ step: RoundStep::Select,
+ });
+ events
+ }
+
+ /// GR-E01: the Scenario threshold by player count (dataset 0.1).
+ fn threshold(&self) -> u32 {
+ match self.players.len() {
+ 0..=2 => 5,
+ 3..=4 => 7,
+ _ => 9,
+ }
+ }
+
+ /// GR-E01..E04: final scoring for the configured mode.
+ fn score(&self) -> Outcome {
+ // GR-E01/P03: a claimed Problem counts its printed value.
+ let total: u32 = self
+ .problems
+ .values()
+ .filter(|p| p.claimed_by.is_some())
+ .map(|p| u32::from(p.value))
+ .sum();
+ let threshold = self.threshold();
+ let group_success = total >= threshold;
+
+ // GR-E03/T02: claimed value −1 per Blame token held.
+ let personal: BTreeMap = self
+ .players
+ .keys()
+ .map(|seat| {
+ let claimed: i32 = self
+ .problems
+ .values()
+ .filter(|p| p.claimed_by == Some(*seat))
+ .map(|p| i32::from(p.value))
+ .sum();
+ let blame = self.players[seat].blame_from.len() as i32;
+ (*seat, claimed - blame)
+ })
+ .collect();
+
+ let coalitions = self.coalitions(&personal);
+
+ let (mastery, winners) = match self.mode {
+ // GR-E02: one shared score; no individual winner.
+ ScoringMode::SharedGround => {
+ let blame: i32 = self
+ .players
+ .values()
+ .map(|p| p.blame_from.len() as i32)
+ .sum();
+ let denied = self.problems.values().filter(|p| p.denied).count() as i32;
+ let claimed = self
+ .problems
+ .values()
+ .filter(|p| p.claimed_by.is_some())
+ .count() as i32;
+ let mastery = claimed - blame - denied;
+ let winners = if group_success {
+ self.players.keys().copied().collect()
+ } else {
+ Vec::new()
+ };
+ (Some(mastery), winners)
+ }
+ // GR-E03: highest personal score, once the group qualifies.
+ // Tiebreak: lower Stress, then more Bonds, then shared.
+ ScoringMode::CommonProblem => {
+ let winners = if group_success {
+ self.best(personal.keys().copied().collect(), |seat| {
+ (
+ personal[&seat],
+ -i32::from(self.players[&seat].stress),
+ self.bond_count(seat),
+ )
+ })
+ } else {
+ Vec::new()
+ };
+ (None, winners)
+ }
+ // GR-E04: highest coalition. Tiebreak: lower combined
+ // Stress, then fewer Blame tokens, then shared.
+ ScoringMode::BondedCoalitions => {
+ let winners = if group_success {
+ let best = self.best((0..coalitions.len()).collect(), |i| {
+ let c = &coalitions[i];
+ let stress: i32 = c
+ .members
+ .iter()
+ .map(|m| i32::from(self.players[m].stress))
+ .sum();
+ let blame: i32 = c
+ .members
+ .iter()
+ .map(|m| self.players[m].blame_from.len() as i32)
+ .sum();
+ (c.score, -stress, -blame)
+ });
+ let mut winners: Vec = best
+ .into_iter()
+ .flat_map(|i| coalitions[i].members.clone())
+ .collect();
+ winners.sort();
+ winners
+ } else {
+ Vec::new()
+ };
+ (None, winners)
+ }
+ };
+
+ Outcome {
+ total,
+ threshold,
+ group_success,
+ personal,
+ coalitions,
+ mastery,
+ winners,
+ }
+ }
+
+ /// Every candidate tied on the ranking key, so a tie is a shared
+ /// result rather than an arbitrary pick (GR-E03/E04).
+ fn best(&self, candidates: Vec, key: impl Fn(T) -> K) -> Vec {
+ let Some(top) = candidates.iter().map(|c| key(*c)).max() else {
+ return Vec::new();
+ };
+ candidates.into_iter().filter(|c| key(*c) == top).collect()
+ }
+
+ fn bond_count(&self, seat: PlayerId) -> i32 {
+ self.relations
+ .iter()
+ .filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat))
+ .count() as i32
+ }
+
+ /// GR-E04: connected components over Bonds only; Rivalries do not
+ /// connect, and an unbonded player is a coalition of one.
+ fn coalitions(&self, personal: &BTreeMap) -> Vec {
+ let mut remaining: Vec = self.players.keys().copied().collect();
+ let mut out = Vec::new();
+ while let Some(seed) = remaining.first().copied() {
+ let mut members = vec![seed];
+ let mut frontier = vec![seed];
+ remaining.retain(|p| *p != seed);
+ while let Some(current) = frontier.pop() {
+ let neighbours: Vec = self
+ .relations
+ .iter()
+ .filter(|(_, rel)| **rel == Relation::Bond)
+ .filter_map(|(pair, _)| {
+ if pair.0 == current {
+ Some(pair.1)
+ } else if pair.1 == current {
+ Some(pair.0)
+ } else {
+ None
+ }
+ })
+ .filter(|n| remaining.contains(n))
+ .collect();
+ for n in neighbours {
+ remaining.retain(|p| *p != n);
+ members.push(n);
+ frontier.push(n);
+ }
+ }
+ members.sort();
+ let score = members.iter().map(|m| personal[m]).sum();
+ out.push(Coalition { members, score });
+ }
+ out
+ }
+
+ /// GR-A11/A12: the sub-choice must name something that exists and
+ /// is in a state the choice can act on.
+ fn check_ground_choice(
+ &self,
+ actor: PlayerId,
+ choice: Option,
+ ) -> Result<(), Rejection> {
+ let bad = |detail: String| Rejection::Game {
+ code: "bad-choice".into(),
+ detail,
+ };
+ let problem = |id: u32| {
+ self.problems
+ .get(&id)
+ .ok_or_else(|| bad(format!("no Problem {id}")))
+ };
+ match choice {
+ None => Ok(()),
+ // GR-A11: only a Denied Problem can be restored.
+ Some(GroundChoice::RestoreProblem { problem: id }) => {
+ if !problem(id)?.denied {
+ return Err(bad(format!("GR-A11: Problem {id} is not Denied")));
+ }
+ Ok(())
+ }
+ // GR-A11: only a face-up Problem can be protected.
+ Some(GroundChoice::ProtectProblem { problem: id }) => {
+ if !problem(id)?.face_up || problem(id)?.denied {
+ return Err(bad(format!("GR-A11: Problem {id} is not face up")));
+ }
+ Ok(())
+ }
+ // GR-A11: the cancelled Attack must actually target us.
+ Some(GroundChoice::CancelAttack { attacker }) => {
+ let aimed_here = self
+ .selections
+ .get(&attacker)
+ .is_some_and(|s| s.action == Action::Attack && s.target == Some(actor));
+ if !aimed_here {
+ return Err(bad(format!(
+ "GR-A11: {attacker} is not attacking this player"
+ )));
+ }
+ Ok(())
+ }
+ // GR-A12/T02: the Blame token must be in front of us.
+ Some(GroundChoice::RemoveBlame { owner }) => {
+ let held = self
+ .players
+ .get(&actor)
+ .is_some_and(|p| p.blame_from.contains(&owner));
+ if !held {
+ return Err(bad(format!("GR-T02: no Blame token from {owner} here")));
+ }
+ Ok(())
+ }
+ // GR-A12: the relation must exist and involve us.
+ Some(GroundChoice::BreakRelation { with }) => {
+ if self.relation_between(actor, with).is_none() {
+ return Err(bad(format!("GR-A12: no relation with {with}")));
+ }
+ Ok(())
+ }
+ Some(GroundChoice::RejectReverse) => Ok(()),
+ }
+ }
+
+ /// 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")))?;
+ let target = self
+ .problems
+ .get(&problem)
+ .ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?;
+ match action {
+ // GR-A13: INVESTIGATE targets a hidden Problem.
+ Action::Investigate if target.face_up => {
+ return Err(bad(format!("GR-A13: Problem {problem} is already face up")));
+ }
+ // GR-A13: SOLVE targets a face-up, non-Denied Problem.
+ Action::Solve if !target.face_up || target.denied => {
+ return Err(bad(format!(
+ "GR-A13: Problem {problem} is not a face-up, non-Denied 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(),
+ ground_modes: BTreeMap::new(),
+ ground_choices: BTreeMap::new(),
+ support_responses: BTreeMap::new(),
+ darvo_targets: BTreeMap::new(),
+ mode: ScoringMode::SharedGround,
+ outcome: None,
+ seed,
+ })
+ }
+
+ 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,
+ "choose_ground_mode" => GroundCommand::ChooseGroundMode {
+ mode: GroundMode::parse(&arg_str("mode")?)?,
+ choice: match step.args.get("choice") {
+ Some(_) => Some(GroundChoice::parse(
+ &arg_str("choice")?,
+ arg_u64("problem").or_else(|| arg_u64("seat")),
+ )?),
+ None => None,
+ },
+ },
+ "choose_darvo_target" => GroundCommand::ChooseDarvoTarget {
+ target: DarvoTarget {
+ problem: arg_u64("problem").map(|p| p as u32),
+ player: match step.args.get("target") {
+ Some(_) => match parse_actor(&arg_str("target")?)? {
+ Actor::Player(seat) => Some(seat),
+ Actor::System => return Err("target may not be SYSTEM".into()),
+ },
+ None => None,
+ },
+ },
+ },
+ "respond_to_support" => GroundCommand::RespondToSupport {
+ response: SupportResponse::parse(&arg_str("response")?)?,
+ },
+ "reveal" => GroundCommand::Reveal,
+ "resolve" => GroundCommand::Resolve,
+ "end_round" => GroundCommand::EndRound,
+ other => return Err(format!("unknown command {other:?}")),
+ };
+ Ok((actor, command))
+ }
+
+ fn round(&self) -> u8 {
+ self.round
+ }
}
#[cfg(test)]
@@ -91,6 +1920,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 +1932,149 @@ mod tests {
solution_deck: vec![],
solution_discard: vec![],
focus: BTreeMap::new(),
+ step: RoundStep::Select,
+ selections: BTreeMap::new(),
+ ground_modes: BTreeMap::new(),
+ ground_choices: BTreeMap::new(),
+ support_responses: BTreeMap::new(),
+ darvo_targets: BTreeMap::new(),
+ mode: ScoringMode::SharedGround,
+ outcome: None,
+ seed: 0,
}
}
+ /// Regression: relation keys must serialize as JSON object keys.
+ /// With a tuple key, `state_hash` panicked on any state holding a
+ /// relation — that is, on almost every real game state.
+ #[test]
+ fn state_with_relations_hashes() {
+ let mut state = tiny_state();
+ state
+ .relations
+ .insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond);
+ let hash = state_hash_hex(&state);
+ assert_eq!(hash.len(), 64);
+ // GR-O05: the key is canonically ordered, so either construction
+ // order yields the same state and the same hash.
+ let mut mirrored = tiny_state();
+ mirrored
+ .relations
+ .insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond);
+ assert_eq!(hash, state_hash_hex(&mirrored));
+ }
+
+ 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]
@@ -117,3 +2087,164 @@ mod tests {
assert_ne!(state_hash_hex(&a), state_hash_hex(&c));
}
}
+
+#[cfg(test)]
+mod bench_shape {
+ use super::*;
+ use cb_game_runtime::{ScenarioGame, Setup};
+
+ /// AM-6 reports events/second; the evidence file converts that to
+ /// rounds and commands per second. Both divisors are pinned here so
+ /// a change to the workload cannot silently rescale the metric.
+ #[test]
+ fn synthetic_round_shape_is_pinned() {
+ let mut state = GroundState::setup(
+ &Setup {
+ players: 3,
+ preset: "standard-3p".into(),
+ patch: BTreeMap::new(),
+ },
+ 1,
+ )
+ .unwrap();
+
+ let mut events = 0;
+ let mut commands = 0;
+ let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| {
+ commands += 1;
+ if let Ok(produced) = state.validate(actor, cmd) {
+ for e in &produced {
+ state.fold(e);
+ }
+ events += produced.len();
+ }
+ };
+
+ for (seat, action, target) in [
+ (0u8, Action::Attack, Some(PlayerId(1))),
+ (2, Action::Support, Some(PlayerId(1))),
+ (1, Action::Ground, None),
+ ] {
+ run(
+ &mut state,
+ Actor::Player(PlayerId(seat)),
+ &GroundCommand::SelectAction {
+ action,
+ target,
+ problem: None,
+ },
+ );
+ }
+ run(&mut state, Actor::System, &GroundCommand::Reveal);
+ run(
+ &mut state,
+ Actor::Player(PlayerId(1)),
+ &GroundCommand::ChooseGroundMode {
+ mode: GroundMode::Gr,
+ choice: None,
+ },
+ );
+ run(&mut state, Actor::System, &GroundCommand::Resolve);
+ run(&mut state, Actor::System, &GroundCommand::EndRound);
+
+ assert_eq!(commands, 7, "commands per synthetic round");
+ assert_eq!(events, 13, "events per synthetic round");
+ }
+}
+
+#[cfg(test)]
+mod replay_probe {
+ use super::*;
+ use cb_events::state_hash_hex;
+ use cb_game_runtime::{ScenarioGame, Setup};
+ use std::time::Instant;
+
+ fn fresh(seed: u64) -> GroundState {
+ GroundState::setup(
+ &Setup {
+ players: 3,
+ preset: "standard-3p".into(),
+ patch: BTreeMap::new(),
+ },
+ seed,
+ )
+ .unwrap()
+ }
+
+ fn record_round(state: &mut GroundState, log: &mut Vec) -> usize {
+ let mut n = 0;
+ let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| {
+ if let Ok(produced) = state.validate(actor, cmd) {
+ for e in &produced {
+ state.fold(e);
+ log.push(e.clone());
+ n += 1;
+ }
+ }
+ };
+ for (seat, action, target) in [
+ (0u8, Action::Attack, Some(PlayerId(1))),
+ (2, Action::Support, Some(PlayerId(1))),
+ (1, Action::Ground, None),
+ ] {
+ run(
+ state,
+ Actor::Player(PlayerId(seat)),
+ &GroundCommand::SelectAction {
+ action,
+ target,
+ problem: None,
+ },
+ );
+ }
+ run(state, Actor::System, &GroundCommand::Reveal);
+ run(
+ state,
+ Actor::Player(PlayerId(1)),
+ &GroundCommand::ChooseGroundMode {
+ mode: GroundMode::Gr,
+ choice: None,
+ },
+ );
+ run(state, Actor::System, &GroundCommand::Resolve);
+ run(state, Actor::System, &GroundCommand::EndRound);
+ n
+ }
+
+ /// AM-7: folding a 100k-event log back into state must stay well
+ /// under the 5s budget, and must be linear in log length.
+ #[test]
+ fn replay_100k_events_is_linear_and_fast() {
+ for target in [10_000usize, 100_000] {
+ let mut log = Vec::with_capacity(target);
+ let mut source = fresh(42);
+ let mut games = 0u64;
+ let mut stalls = 0;
+ while log.len() < target {
+ if source.outcome.is_some() {
+ games += 1;
+ source = fresh(42 + games);
+ }
+ if record_round(&mut source, &mut log) == 0 {
+ stalls += 1;
+ assert!(stalls < 10, "round produced no events; builder stalled");
+ }
+ }
+ let start = Instant::now();
+ let mut state = fresh(42);
+ for event in &log {
+ state.fold(event);
+ }
+ let hash = state_hash_hex(&state);
+ let elapsed = start.elapsed();
+ println!(
+ "replay {} events in {:?} ({:.0} events/s), hash {}",
+ log.len(),
+ elapsed,
+ log.len() as f64 / elapsed.as_secs_f64(),
+ &hash[..8]
+ );
+ assert!(elapsed.as_secs_f64() < 5.0, "AM-7: 100k replay under 5s");
+ }
+ }
+}
diff --git a/history/260731-inner-loop-retrospective.md b/history/260731-inner-loop-retrospective.md
new file mode 100644
index 0000000..c6c6f8a
--- /dev/null
+++ b/history/260731-inner-loop-retrospective.md
@@ -0,0 +1,116 @@
+# Inner-loop retrospective — after CB-WP-0001 (first full pass)
+
+Date: 2026-07-31
+Subject: `specs/InnerLoop.md` v0.2 → v1.0
+Evidence: `evidence/CB-EV-0001-game-kernel.md`
+
+The loop has now survived one full pass, from SOTA survey through an
+adversarial review, an ADR, two specs, and six code iterations that
+ended in a committed acceptance run. This is what it got right, what it
+missed, and what changes in v1.0.
+
+## What earned its cost
+
+**The adversarial review (Step 2).** On its single use it produced one
+blocking finding and three significant ones. The blocking one mattered:
+the survey claimed boardgame.io's degradation was "architectural, not a
+tuning artifact", and the challenge pointed at the `disableUndo` flag
+that the claim had not accounted for. The resolution was a rerun with
+undo disabled, not a rhetorical defence — degradation persisted (0.66×
+at 40k, still DNF at 100k), so the overclaim was retracted and two
+mechanisms were attributed separately. A survey that had gone straight
+to an ADR would have carried a false claim into the decision.
+
+**The parity-cap rule.** It stopped the boardgame.io comparison from
+being written up as a verdict. The final evidence file says the ~450×
+command-rate ratio is a direction, not a verdict, and lists the four
+reasons it is not like-for-like. That paragraph exists because the rule
+required it.
+
+**Deferring rules that need a decision.** Four rules (GR-L02, GR-A05,
+GR-A11, GR-A12) were left unimplemented across two iterations rather
+than given invented defaults, then implemented as explicit commands.
+No scenario claimed coverage of them in the interim. This is the single
+most useful habit the pass produced and v1.0 promotes it to a rule.
+
+**The provisional mechanism.** Ten U-items with `provisional: true`
+scenarios meant an underdetermined rule cost a scenario tag, not a
+kernel decision. One further ambiguity (GR-E02's "successes") was found
+during implementation and captured the same way.
+
+## What the loop missed
+
+**Both serious errors in this pass were measurement errors, and the
+loop caught neither.** Review caught claims; nothing caught numbers.
+
+1. The boardgame.io harness v1 reported "8.4s for 100k moves". It was
+ measuring rejected no-ops — every move was refused. Caught by
+ noticing the error text in output, not by any gate.
+2. The Rust benchmark reported **9.3M events/s on a flat curve** and
+ would have been published as a 93× beat of AM-6. The workload had a
+ player selecting SUPPORT while parked at Stress 4, so GR-R03 rejected
+ it, rounds never completed, and throughput was computed for rounds
+ that never happened. Caught only because a probe test asserted a
+ round produces events at all. The corrected number is 5.6× lower.
+
+Both share a shape: **the harness ran successfully while doing no
+work**, and success was inferred from "it completed" rather than from
+"it did the thing". This is the dominant failure mode of an agent-driven
+loop, because an agent will happily report a fast number.
+
+**Scaffold code with unexercised paths.** T07 produced a compiling,
+"green" workspace that contained two latent defects: relations keyed by
+a tuple, which JSON cannot serialize, so `state_hash` would have
+panicked on any state holding a relation; and `setup.patch`, which was
+parsed and silently discarded, so every scenario using it would have
+tested the wrong initial state. Both were invisible until real scenarios
+exercised them. Green gates on a scaffold mean very little.
+
+**Metrics were specified without checking they were reachable or
+instrumented.** AM-4 (≤20 crates) is unreachable given the spec's own
+K5 and K7 contracts, which require ChaCha and SHA-256 and cost 12 crates
+between them — the metric was written before those contracts existed and
+never revisited. AM-12 (cost in USD) was specified in detail, with a
+price sheet and a formula, and then never instrumented, so it could not
+be computed at all. A metric with no named instrument is a wish.
+
+**The chaos roll never fired.** Across the pass the d10 never selected
+a tier different from the structural one, so the mechanism is untested
+in practice. It is retained, but v1.0 requires the roll be recorded even
+when it changes nothing, so its absence is visible rather than assumed.
+
+**The coverage gate counts tags, not behaviour.** `make coverage`
+reports 58/58 by comparing rule IDs in the spec against `covers:` lists.
+It catches invented IDs and outright gaps, which is real, but a scenario
+can name a rule it does not exercise. 100% on that gate is not proof
+AM-1 is met, and v1.0 says so where the number is reported.
+
+## Changes in v1.0
+
+1. **Measurement validity (new, Step 5).** Every benchmark and harness
+ must assert it performed the work it reports — a positive control.
+ A number from a run that cannot prove it did the work is void.
+2. **Metric feasibility (Step 4).** Each acceptance metric names its
+ instrument and is checked reachable against the contracts in the same
+ spec. Re-checked whenever a contract is added.
+3. **No silently-ignored input (new).** A parsed-but-unused field is a
+ defect. Inputs are honoured or rejected, never dropped.
+4. **Decisions get commands, not defaults (new).** A rule requiring a
+ participant's choice is implemented as a command; if it is not
+ implemented yet, nothing claims coverage of it.
+5. **Scaffolds are exercised or marked (new).** Scaffold code paths not
+ reached by a test are marked as unexercised; a scaffold's green gates
+ are not evidence.
+6. **Evidence states what it does not support (Step 5).** Every
+ cross-runtime comparison names its disanalogies explicitly.
+7. **The chaos roll is always recorded**, including when it does not
+ change the tier.
+
+## What did not need changing
+
+The five steps, the four-dimension rubric, the tier system's structural
+triggers, the ADR gate ("no implementation code before the ADR is
+committed"), the survey template, and the agentic-efficiency
+requirements all held up. The gate in particular was never
+uncomfortable — by the time the ADR was written the decision was easy,
+which is what a good gate feels like.
diff --git a/scenarios/ground/gr-a01-investigate.yaml b/scenarios/ground/gr-a01-investigate.yaml
new file mode 100644
index 0000000..e5de082
--- /dev/null
+++ b/scenarios/ground/gr-a01-investigate.yaml
@@ -0,0 +1,43 @@
+scenario: ground/gr-a01-investigate
+description: >
+ INVESTIGATE reveals the chosen hidden Problem and draws one Solution
+ (GR-A01). Selecting an already face-up Problem is rejected (GR-A13).
+covers: [GR-A01, GR-S01]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ # GR-A13: Problem 1 is the Surface Problem, already face up.
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 1 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: ProblemRevealed
+ problem: 2
+ - kind: SolutionDrawn
+ player: 0
+ state:
+ "problems.2.face_up": true
+ "problems.3.face_up": true
+ # GR-S02 dealt two; INVESTIGATE drew a third. The suit is the
+ # golden value for seed 42 under the GR-S04 shuffle.
+ "players.0.hand.2.suit": Change
+ rejects: [1]
diff --git a/scenarios/ground/gr-a02-solve.yaml b/scenarios/ground/gr-a02-solve.yaml
new file mode 100644
index 0000000..86874f3
--- /dev/null
+++ b/scenarios/ground/gr-a02-solve.yaml
@@ -0,0 +1,43 @@
+scenario: ground/gr-a02-solve
+description: >
+ SOLVE discards a Solution of the Problem's suit and claims it
+ (GR-A02). A second solver of the same Problem the same round spends
+ nothing, because an earlier resolver in Lead order already claimed it.
+covers: [GR-A02, GR-R07, GR-O04, GR-P04]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.hand": [{ suit: Clarify }]
+ "players.1.hand": [{ suit: Clarify }]
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: SOLVE, problem: 1 }
+ - actor: P2
+ cmd: select_action
+ args: { action: SOLVE, problem: 1 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: SolutionDiscarded
+ player: 0
+ - kind: ProblemClaimed
+ problem: 1
+ by: 0
+ state:
+ # Problem 1 is the Clarify Surface Problem (GR-S01).
+ "problems.1.claimed_by": 0
+ "players.0.hand": []
+ # GR-A02: P2 resolved second, so its Solution is not spent.
+ "players.1.hand": [{ suit: Clarify }]
+ rejects: []
diff --git a/scenarios/ground/gr-a04-bond-support.yaml b/scenarios/ground/gr-a04-bond-support.yaml
new file mode 100644
index 0000000..d70d957
--- /dev/null
+++ b/scenarios/ground/gr-a04-bond-support.yaml
@@ -0,0 +1,48 @@
+scenario: ground/gr-a04-bond-support
+description: >
+ Support through an existing Bond: −2 Stress and the target's Freedom
+ token is readied (GR-A04, GR-F04). A Support with no relation forms no
+ Bond, because GR-L02 requires the target's consent.
+covers: [GR-A04, GR-F04, GR-L02, GR-L05]
+provisional: true
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "relations.0-1": Bond
+ "players.1.stress": 3
+ "players.1.freedom_ready": false
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: SUPPORT, target: P2 }
+ # No relation with P3: Stress drops but no Bond forms without consent.
+ - actor: P2
+ cmd: select_action
+ args: { action: SUPPORT, target: P3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: StressSet
+ player: 1
+ stress: 1
+ - kind: FreedomReadied
+ player: 1
+ - kind: StressSet
+ player: 2
+ stress: 1
+ state:
+ "players.1.stress": 1
+ "players.1.freedom_ready": true
+ "players.2.stress": 1
+ # No Bond formed with P3: the relation map is unchanged.
+ "relations": { "0-1": Bond }
+ rejects: []
diff --git a/scenarios/ground/gr-a05-support-response.yaml b/scenarios/ground/gr-a05-support-response.yaml
new file mode 100644
index 0000000..1208435
--- /dev/null
+++ b/scenarios/ground/gr-a05-support-response.yaml
@@ -0,0 +1,57 @@
+scenario: ground/gr-a05-support-response
+description: >
+ A Support target answers after Reveal: through a Rivalry they flip it
+ to a Bond or break it (GR-A05); with no relation a Bond forms only if
+ they accept (GR-L02). A player not receiving Support cannot answer.
+covers: [GR-A05, GR-L02, GR-A03]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "relations.0-1": Rivalry
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: SUPPORT, target: P2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: SUPPORT, target: P3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-A05: P2 is supported through a Rivalry, so it may flip.
+ - actor: P2
+ cmd: respond_to_support
+ args: { response: flip_to_bond }
+ # GR-L02: P3 has no relation with its supporter, so it accepts.
+ - actor: P3
+ cmd: respond_to_support
+ args: { response: accept_bond }
+ # P1 received no Support this round.
+ - actor: P1
+ cmd: respond_to_support
+ args: { response: accept_bond }
+ # GR-A05: a flip is not available where no relation exists.
+ - actor: P3
+ cmd: respond_to_support
+ args: { response: flip_to_bond }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: SupportAnswered
+ player: 1
+ response: FlipToBond
+ - kind: RelationFormed
+ relation: Bond
+ state:
+ "relations.0-1": Bond
+ "relations.1-2": Bond
+ "players.1.stress": 1
+ "players.2.stress": 1
+ rejects: [6, 7]
diff --git a/scenarios/ground/gr-a07-bond-flip.yaml b/scenarios/ground/gr-a07-bond-flip.yaml
new file mode 100644
index 0000000..b90030b
--- /dev/null
+++ b/scenarios/ground/gr-a07-bond-flip.yaml
@@ -0,0 +1,38 @@
+scenario: ground/gr-a07-bond-flip
+description: >
+ Attack through an existing Bond: +2 Stress and the Bond flips to a
+ Rivalry (GR-A07, GR-L04).
+covers: [GR-A07, GR-L04, GR-O05]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "relations.0-1": Bond
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: ATTACK, target: P2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: StressSet
+ player: 1
+ stress: 4
+ - kind: RelationFormed
+ relation: Rivalry
+ state:
+ "players.1.stress": 4
+ "relations.0-1": Rivalry
+ rejects: []
diff --git a/scenarios/ground/gr-a08-rivalry-break.yaml b/scenarios/ground/gr-a08-rivalry-break.yaml
new file mode 100644
index 0000000..2ea741f
--- /dev/null
+++ b/scenarios/ground/gr-a08-rivalry-break.yaml
@@ -0,0 +1,42 @@
+scenario: ground/gr-a08-rivalry-break
+description: >
+ Attack through an existing Rivalry: +2 Stress and the relation breaks
+ (GR-A08, GR-L04). Protection absorbs a separate Attack (GR-A09).
+covers: [GR-A08, GR-A09, GR-T01]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "relations.0-1": Rivalry
+ "players.2.protection": 1
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: ATTACK, target: P2 }
+ # P3 is protected, so this Attack is cancelled and costs the token.
+ - actor: P2
+ cmd: select_action
+ args: { action: ATTACK, target: P3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: StressSet
+ player: 1
+ stress: 4
+ - kind: RelationBroken
+ - kind: AttackCancelled
+ target: 2
+ state:
+ "players.1.stress": 4
+ "players.2.stress": 2
+ "players.2.protection": 0
+ rejects: []
diff --git a/scenarios/ground/gr-a10-ground-gr.yaml b/scenarios/ground/gr-a10-ground-gr.yaml
new file mode 100644
index 0000000..bb93c1a
--- /dev/null
+++ b/scenarios/ground/gr-a10-ground-gr.yaml
@@ -0,0 +1,55 @@
+scenario: ground/gr-a10-ground-gr
+description: >
+ GROUND—GR (Ground & Restate): the mode is chosen after Reveal
+ (GR-R05), and on resolution the player takes −2 Stress and readies
+ their Freedom token (GR-A10, GR-F04). Resolution refuses to start
+ while any revealed GROUND still lacks a mode.
+covers: [GR-R05, GR-A10]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.stress": 5
+ "players.0.freedom_ready": false
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: GROUND }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-R05: no mode chosen yet, so resolution is refused.
+ - actor: SYSTEM
+ cmd: resolve
+ # GR-R05: only a player who revealed GROUND may choose a mode.
+ - actor: P2
+ cmd: choose_ground_mode
+ args: { mode: GR }
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: GR }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: GroundModeChosen
+ player: 0
+ mode: Gr
+ - kind: StressSet
+ player: 0
+ stress: 3
+ - kind: FreedomReadied
+ player: 0
+ state:
+ "players.0.stress": 3
+ "players.0.freedom_ready": true
+ "step": Resolve
+ rejects: [4, 5]
diff --git a/scenarios/ground/gr-a11-ground-ou.yaml b/scenarios/ground/gr-a11-ground-ou.yaml
new file mode 100644
index 0000000..97c2167
--- /dev/null
+++ b/scenarios/ground/gr-a11-ground-ou.yaml
@@ -0,0 +1,43 @@
+scenario: ground/gr-a11-ground-ou
+description: >
+ GROUND—OU restores a Denied Problem (GR-A11), and a Denied Problem is
+ not otherwise revealable (GR-P02). A mode's sub-choice must belong to
+ that mode (GR-A10).
+covers: [GR-A11, GR-P02, GR-P01]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "problems.2.denied": true
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: GROUND }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-A10: GROUND—GR takes no sub-choice.
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: GR, choice: restore_problem, problem: 2 }
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: OU, choice: restore_problem, problem: 2 }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: ProblemRestored
+ problem: 2
+ state:
+ "problems.2.denied": false
+ "problems.2.face_up": true
+ rejects: [4]
diff --git a/scenarios/ground/gr-a12-ground-nd.yaml b/scenarios/ground/gr-a12-ground-nd.yaml
new file mode 100644
index 0000000..e363f01
--- /dev/null
+++ b/scenarios/ground/gr-a12-ground-nd.yaml
@@ -0,0 +1,47 @@
+scenario: ground/gr-a12-ground-nd
+description: >
+ GROUND—ND removes a Blame token from its player (GR-A12, GR-T02). The
+ choice must name a token that is actually there, and a relation break
+ must name a relation that exists.
+covers: [GR-A12, GR-T02, GR-T03]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.blame_from": [1]
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: GROUND }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-T02: P3 has no Blame token in front of P1.
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: ND, choice: remove_blame, seat: 2 }
+ # GR-A12: no relation with P2 exists to break.
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: ND, choice: break_relation, seat: 1 }
+ - actor: P1
+ cmd: choose_ground_mode
+ args: { mode: ND, choice: remove_blame, seat: 1 }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: BlameRemoved
+ player: 0
+ owner: 1
+ state:
+ "players.0.blame_from": []
+ rejects: [4, 5]
diff --git a/scenarios/ground/gr-d01-darvo-trigger.yaml b/scenarios/ground/gr-d01-darvo-trigger.yaml
new file mode 100644
index 0000000..19ed4ab
--- /dev/null
+++ b/scenarios/ground/gr-d01-darvo-trigger.yaml
@@ -0,0 +1,42 @@
+scenario: ground/gr-d01-darvo-trigger
+description: >
+ DARVO trigger: a player who ends the round at Stress 5 with the marker
+ OFF is set to DENY (GR-D01, GR-R08). Stress clamps at 5 on every
+ application (GR-F01 under the U2 default).
+covers: [GR-D01, GR-R08, GR-F01]
+provisional: true
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.1.stress": 4
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: ATTACK, target: P2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: ATTACK, target: P3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+ - actor: SYSTEM
+ cmd: end_round
+expect:
+ events:
+ - kind: StressSet
+ player: 1
+ stress: 5
+ - kind: DarvoTriggered
+ player: 1
+ state:
+ "players.1.stress": 5
+ "players.1.darvo": Deny
+ "round": 2
+ rejects: []
diff --git a/scenarios/ground/gr-d03-darvo-deny.yaml b/scenarios/ground/gr-d03-darvo-deny.yaml
new file mode 100644
index 0000000..2ca341a
--- /dev/null
+++ b/scenarios/ground/gr-d03-darvo-deny.yaml
@@ -0,0 +1,48 @@
+scenario: ground/gr-d03-darvo-deny
+description: >
+ The DENY stage turns one face-up, unsolved, unprotected Problem face
+ down and Denies it (GR-D03), then the sequence advances to ATTACK
+ (GR-D02). A protected Problem is not a legal DENY target (GR-A11).
+covers: [GR-D02, GR-D03, GR-P01]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.darvo": Deny
+ "problems.2.face_up": true
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-D02: only a player with a live sequence names a stage target.
+ - actor: P2
+ cmd: choose_darvo_target
+ args: { problem: 1 }
+ - actor: P1
+ cmd: choose_darvo_target
+ args: { problem: 1 }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: ProblemDenied
+ problem: 1
+ - kind: DarvoAdvanced
+ player: 0
+ stage: Attack
+ state:
+ "problems.1.denied": true
+ "problems.1.face_up": false
+ "players.0.darvo": Attack
+ rejects: [4]
diff --git a/scenarios/ground/gr-d04-darvo-attack.yaml b/scenarios/ground/gr-d04-darvo-attack.yaml
new file mode 100644
index 0000000..d6e12f8
--- /dev/null
+++ b/scenarios/ground/gr-d04-darvo-attack.yaml
@@ -0,0 +1,53 @@
+scenario: ground/gr-d04-darvo-attack
+description: >
+ The ATTACK stage makes one extra Attack under the normal relation
+ rules and places the Focus token beside the target (GR-D04), then
+ advances to REVERSE.
+covers: [GR-D04, GR-T03]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.darvo": Attack
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ # GR-D04: the extra Attack targets another player.
+ - actor: P1
+ cmd: choose_darvo_target
+ args: { target: P1 }
+ - actor: P1
+ cmd: choose_darvo_target
+ args: { target: P2 }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: StressSet
+ player: 1
+ stress: 3
+ - kind: FocusPlaced
+ owner: 0
+ target: 1
+ - kind: DarvoAdvanced
+ player: 0
+ stage: Reverse
+ state:
+ "players.1.stress": 3
+ "focus.0": 1
+ "players.0.darvo": Reverse
+ # GR-L03: the extra Attack forms a Rivalry like any other.
+ "relations.0-1": Rivalry
+ rejects: [4]
diff --git a/scenarios/ground/gr-d05-darvo-reverse.yaml b/scenarios/ground/gr-d05-darvo-reverse.yaml
new file mode 100644
index 0000000..77efb18
--- /dev/null
+++ b/scenarios/ground/gr-d05-darvo-reverse.yaml
@@ -0,0 +1,54 @@
+scenario: ground/gr-d05-darvo-reverse
+description: >
+ The REVERSE stage flips Focus to Blame in front of the Focus holder,
+ gives them +1 Stress and the owner one Protection token, then the
+ owner takes −2 Stress and the sequence ends (GR-D05, GR-D07).
+covers: [GR-D05, GR-D07, GR-T01]
+provisional: true
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.darvo": Reverse
+ "players.0.stress": 3
+ "focus.0": 1
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: FocusFlippedToBlame
+ owner: 0
+ target: 1
+ - kind: StressSet
+ player: 1
+ stress: 3
+ - kind: ProtectionGained
+ player: 0
+ - kind: StressSet
+ player: 0
+ stress: 1
+ - kind: DarvoEnded
+ player: 0
+ state:
+ "players.1.blame_from": [0]
+ "players.1.stress": 3
+ "players.0.protection": 1
+ "players.0.stress": 1
+ # GR-D07: the marker is OFF, so a new sequence can trigger later.
+ "players.0.darvo": Off
+ "focus": {}
+ rejects: []
diff --git a/scenarios/ground/gr-d06-darvo-early-end.yaml b/scenarios/ground/gr-d06-darvo-early-end.yaml
new file mode 100644
index 0000000..a5dd95a
--- /dev/null
+++ b/scenarios/ground/gr-d06-darvo-early-end.yaml
@@ -0,0 +1,43 @@
+scenario: ground/gr-d06-darvo-early-end
+description: >
+ A Support through a Bond that existed before this round cancels the
+ current stage and ends the sequence; the placed Focus token is removed
+ (GR-D06, GR-A04, GR-L05).
+covers: [GR-D06, GR-L05]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.darvo": Attack
+ "focus.0": 2
+ "relations.0-1": Bond
+commands:
+ - actor: P2
+ cmd: select_action
+ args: { action: SUPPORT, target: P1 }
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: P1
+ cmd: choose_darvo_target
+ args: { target: P3 }
+ - actor: SYSTEM
+ cmd: resolve
+expect:
+ events:
+ - kind: DarvoEnded
+ player: 0
+ state:
+ "players.0.darvo": Off
+ # GR-D06: the stage never fired, so P3 took no extra Attack.
+ "players.2.stress": 2
+ "focus": {}
+ rejects: []
diff --git a/scenarios/ground/gr-e02-shared-ground.yaml b/scenarios/ground/gr-e02-shared-ground.yaml
new file mode 100644
index 0000000..8182ef2
--- /dev/null
+++ b/scenarios/ground/gr-e02-shared-ground.yaml
@@ -0,0 +1,50 @@
+scenario: ground/gr-e02-shared-ground
+description: >
+ SHARED GROUND scoring after Round 5 (GR-R09, GR-E01, GR-E02): one
+ shared total against the player-count threshold, with a Mastery
+ rating reduced by each Blame token and each Denied Problem.
+covers: [GR-R09, GR-E01, GR-E02, GR-P03]
+provisional: true
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "round": 5
+ "mode": SharedGround
+ # 3p threshold is 7; claimed values 1+3 fall short.
+ "problems.1.claimed_by": 0
+ "problems.3.claimed_by": 1
+ "problems.2.denied": true
+ "players.2.blame_from": [0]
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+ - actor: SYSTEM
+ cmd: end_round
+expect:
+ events:
+ - kind: GameEnded
+ state:
+ "outcome.total": 4
+ "outcome.threshold": 7
+ "outcome.group_success": false
+ # 2 claimed Problems, −1 Blame, −1 Denied.
+ "outcome.mastery": 0
+ "outcome.winners": []
+ # GR-R09: the game ended rather than advancing to Round 6.
+ "round": 5
+ "step": End
+ rejects: []
diff --git a/scenarios/ground/gr-e04-coalitions.yaml b/scenarios/ground/gr-e04-coalitions.yaml
new file mode 100644
index 0000000..d9771ec
--- /dev/null
+++ b/scenarios/ground/gr-e04-coalitions.yaml
@@ -0,0 +1,54 @@
+scenario: ground/gr-e04-coalitions
+description: >
+ BONDED COALITIONS scoring (GR-E04): Bond networks score together,
+ Rivalries do not connect, and an unbonded player is a coalition of
+ one. Personal scores subtract Blame (GR-E03, GR-T02).
+covers: [GR-E03, GR-E04, GR-O03]
+provisional: true
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "round": 5
+ "mode": BondedCoalitions
+ # Values 1+2+3 = 6 claimed; 3p threshold is 7, so no group success.
+ "problems.1.claimed_by": 0
+ "problems.2.claimed_by": 1
+ "problems.3.claimed_by": 2
+ "relations.0-1": Bond
+ "relations.1-2": Rivalry
+ "players.0.blame_from": [2]
+commands:
+ - actor: P1
+ cmd: select_action
+ args: { action: SUPPORT, target: P2 }
+ - actor: P2
+ cmd: select_action
+ args: { action: SUPPORT, target: P1 }
+ - actor: P3
+ cmd: select_action
+ args: { action: INVESTIGATE, problem: 2 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+ - actor: SYSTEM
+ cmd: end_round
+expect:
+ events:
+ - kind: GameEnded
+ state:
+ "outcome.total": 6
+ "outcome.group_success": false
+ # P1 claimed value 1 less one Blame; P2 value 2; P3 value 3.
+ "outcome.personal.0": 0
+ "outcome.personal.1": 2
+ "outcome.personal.2": 3
+ # GR-E04: P1+P2 are Bonded; the Rivalry leaves P3 solo.
+ "outcome.coalitions.0.members": [0, 1]
+ "outcome.coalitions.0.score": 2
+ "outcome.coalitions.1.members": [2]
+ "outcome.coalitions.1.score": 3
+ rejects: []
diff --git a/scenarios/ground/gr-f02-no-gate.yaml b/scenarios/ground/gr-f02-no-gate.yaml
new file mode 100644
index 0000000..105a368
--- /dev/null
+++ b/scenarios/ground/gr-f02-no-gate.yaml
@@ -0,0 +1,29 @@
+scenario: ground/gr-f02-no-gate
+description: >
+ Below the stress gate every Action is selectable (GR-F02): at Stress 3
+ a player may choose SOLVE, which GR-R03 would refuse at Stress 4.
+covers: [GR-F02]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+ "players.0.stress": 3
+ "players.1.stress": 4
+ "players.0.hand": [{ suit: Clarify }]
+ "players.1.hand": [{ suit: Clarify }]
+commands:
+ # GR-F02: Stress 3 is below the gate, so SOLVE is available.
+ - actor: P1
+ cmd: select_action
+ args: { action: SOLVE, problem: 1 }
+ # GR-R03: the same Action at Stress 4 is refused.
+ - actor: P2
+ cmd: select_action
+ args: { action: SOLVE, problem: 1 }
+expect:
+ state:
+ "selections.0.action": Solve
+ rejects: [1]
diff --git a/scenarios/ground/gr-r02-select.yaml b/scenarios/ground/gr-r02-select.yaml
new file mode 100644
index 0000000..117d9c7
--- /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: INVESTIGATE, problem: 3 }
+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/scenarios/ground/gr-r06-round-resolve.yaml b/scenarios/ground/gr-r06-round-resolve.yaml
new file mode 100644
index 0000000..679a40a
--- /dev/null
+++ b/scenarios/ground/gr-r06-round-resolve.yaml
@@ -0,0 +1,50 @@
+scenario: ground/gr-r06-round-resolve
+description: >
+ A full round: Select, Reveal, Resolve in GR-R06 step order, End. An
+ unrelated Attack raises Stress and forms a Rivalry; a Support with no
+ relation lowers Stress without forming a Bond.
+covers: [GR-R01, GR-R04, GR-R06, GR-R07, GR-R08, GR-A03, GR-A06, GR-L01, GR-L03, GR-F01]
+provisional: false
+seed: 42
+setup:
+ players: 3
+ preset: standard-3p
+ patch:
+ "lead": 0
+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: 3 }
+ - actor: SYSTEM
+ cmd: reveal
+ - actor: SYSTEM
+ cmd: resolve
+ - actor: SYSTEM
+ cmd: end_round
+expect:
+ events:
+ - kind: Revealed
+ # GR-R06: Support (step 2) resolves before Attack (step 5).
+ - kind: StressSet
+ player: 2
+ stress: 1
+ - kind: StressSet
+ player: 1
+ stress: 3
+ - kind: RelationFormed
+ relation: Rivalry
+ - kind: RoundEnded
+ round: 2
+ state:
+ "round": 2
+ "lead": 1
+ "players.1.stress": 3
+ "players.2.stress": 1
+ "step": Select
+ rejects: []
diff --git a/scenarios/ground/smoke-setup.yaml b/scenarios/ground/smoke-setup.yaml
index c665ac3..a5304fc 100644
--- a/scenarios/ground/smoke-setup.yaml
+++ b/scenarios/ground/smoke-setup.yaml
@@ -2,7 +2,7 @@ scenario: ground/smoke-setup
description: >
Smoke scenario for the T07 scaffold: exercises the documented file format
end to end. Assertions are the GR-S setup facts; execution lands in T08.
-covers: [GR-S02, GR-S03, GR-O01]
+covers: [GR-S02, GR-S03, GR-S04, GR-O01, GR-O02]
provisional: false
seed: 42
setup:
@@ -14,4 +14,10 @@ expect:
"round": 1
"players.0.stress": 2
"players.0.freedom_ready": true
+ "players.0.darvo": Off
+ "players.0.protection": 0
+ "players.0.blame_from": []
+ # GR-S04: 24 core Solutions less the 2 dealt to each of 3 players.
+ "solution_deck.17.suit": Change
+ "solution_discard": []
rejects: []
diff --git a/specs/InnerLoop.md b/specs/InnerLoop.md
index 84b4591..e5c35a2 100644
--- a/specs/InnerLoop.md
+++ b/specs/InnerLoop.md
@@ -1,9 +1,11 @@
# The Inner Loop — Assimilate and Surpass
-Status: **v0.2 draft** — becomes v1.0 only after surviving its first full
-pass (CB-WP-0001-T09 retrospective). v0.2 adds loop tiers with the chaos
-roll, adversarial survey review, and the runnable-baseline option
-(maintainer decision, 2026-07-31).
+Status: **v1.0** — survived its first full pass (CB-WP-0001, the GROUND
+game kernel) and was corrected from it on 2026-07-31. Changes from v0.2:
+measurement validity (the positive control), metric feasibility and
+instrument naming, four implementation rules the pass earned, and the
+requirement that evidence state what it does not support. Rationale and
+the failures behind each: `history/260731-inner-loop-retrospective.md`.
Normative process for building every Clay-Borg capability. Referenced by
all workplans. The loop's own optimization target is **agentic efficiency**:
@@ -42,7 +44,10 @@ are never skipped for code-producing work.
(`shuf -i 1-10 -n 1`). On a **10**, the tier is instead picked uniformly at
random (`shuf -e S M L -n 1`), overriding the structural derivation — up or
down. Both rolls are recorded in the tier declaration
-(`tier: M (structural L, chaos 10→M)`). Purpose: an occasional random
+(`tier: M (structural L, chaos 10→M)`). **Record the roll every time,
+including when it changes nothing** (`tier: L (structural L, chaos 4)`),
+so a mechanism that never fires is visible rather than assumed. Purpose:
+an occasional random
reweighting keeps the classification honest — arguing everything into S
stops paying off when audits can compare argued tiers against the random
sample — and occasionally forces a deep look at something "obviously
@@ -115,6 +120,18 @@ the conventions in [MetricsAndScenarios.md](MetricsAndScenarios.md),
including the rule that metric selection itself passes through a mini
research step (metric provenance).
+**Every metric names its instrument, and is checked reachable.** A row
+in the acceptance table carries the command that produces its number.
+A metric with no named instrument is a wish, not a metric. A metric must
+also be checked against the contracts in the *same spec*: if a contract
+makes a target unreachable, one of the two is wrong and the conflict is
+resolved when it is noticed, not at the acceptance run. Re-check the
+table whenever a contract is added.
+
+*(v1.0, from CB-WP-0001: AM-4's ≤20-crate target was made unreachable by
+the K5 and K7 contracts written after it, and AM-12's cost metric was
+fully specified and never instrumented, so it could not be computed.)*
+
### Step 5 — Code loop
Implement iteratively. Each iteration:
@@ -129,6 +146,40 @@ comparison numbers are committed as an evidence file
(`evidence/CB-EV-NNNN-.md`). A failed scenario must yield a replay
artifact an agent can re-execute locally.
+#### Measurement validity — the positive control
+
+**Every benchmark and harness must assert that it performed the work it
+reports.** Completing without error is not evidence of having done
+anything: a loop whose commands are all rejected runs fast and reports a
+throughput for work that never happened.
+
+Concretely, a measurement harness must, on every run:
+
+- assert the unit of work produced its expected effect (events applied,
+ rows written, moves accepted) — not merely that the call returned;
+- fail loudly rather than report a number when that assertion fails;
+- state the divisor used to convert raw timings into the metric's unit,
+ pinned by a test so a workload change cannot silently rescale it.
+
+**A number from a run that cannot prove it did the work is void** and
+must not reach an evidence file.
+
+*(v1.0, from CB-WP-0001: both serious errors in the first pass were of
+exactly this shape. A JS harness reported 8.4s for 100k moves while
+every move was being rejected, and a Rust benchmark reported 9.3M
+events/s — a 93× beat — while most rounds never completed because a
+stress gate rejected one player's action. The corrected figure was 5.6×
+lower. Adversarial review caught neither; both were claims about
+numbers, and review reads prose.)*
+
+#### Evidence states what it does not support
+
+An evidence file that compares across runtimes, languages, or feature
+sets **names the disanalogies explicitly**, in the same section as the
+number. The reader must not have to infer that a ratio is not
+like-for-like. This is the parity-cap rule applied to the write-up:
+state the claim you will defend, and the claim you are not making.
+
---
## The four-dimension rubric
@@ -171,6 +222,33 @@ Which candidate leads per dimension; what none of them do well
---
+## Implementation rules the first pass earned
+
+These are cheap, and each exists because its absence cost something in
+CB-WP-0001. See `history/260731-inner-loop-retrospective.md`.
+
+1. **No silently-ignored input.** A field that is parsed and then unused
+ is a defect, not a stub. Inputs are honoured or rejected with an
+ error — never dropped. *(A scenario `setup.patch` was parsed and
+ discarded; every scenario using it would have tested the wrong
+ initial state while passing.)*
+2. **Decisions get commands, not defaults.** A rule that requires a
+ participant's choice is implemented as a command carrying that
+ choice. Until it is, **nothing claims coverage of it** — no tag, no
+ scenario, no acceptance row. Inventing a default to make a rule
+ "done" is the failure this prevents.
+3. **Scaffolds are exercised or marked.** A scaffold's green gates are
+ not evidence. Any scaffold path no test reaches is marked as
+ unexercised. *(A compiling, fully-green scaffold shipped a state-hash
+ that would panic on any state holding a relation.)*
+4. **Coverage gates that count tags say so.** A gate comparing rule IDs
+ against `covers:` lists proves no rule is unclaimed and no claimed
+ rule is invented. It does **not** prove a scenario exercises what it
+ names. Wherever such a number is reported, that limit is reported
+ with it.
+
+---
+
## Agentic-efficiency requirements
The loop exists to be driven by agents. Therefore:
@@ -202,6 +280,12 @@ A capability has completed the loop when all of the following are committed:
- [ ] specs/.md with acceptance-metrics table
- [ ] passing scenarios covering every numbered spec rule
- [ ] evidence/CB-EV-NNNN with final comparison vs baseline, no `unmeasured`
+- [ ] every reported number produced by a harness with a positive
+ control; any metric that could not be instrumented is recorded as
+ uncomputable rather than estimated
+- [ ] every unmet metric reported as unmet, with attribution and the
+ options for resolving it — a missed target is an output of the
+ loop, not a reason to move the target quietly
- [ ] retrospective note (may be one paragraph appended to the evidence
file): what the loop itself should change
```
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);
}
}
diff --git a/tools/rule-coverage.py b/tools/rule-coverage.py
new file mode 100755
index 0000000..306fa99
--- /dev/null
+++ b/tools/rule-coverage.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""AM-1 / M-D1-COV: every numbered GR-rule needs >=1 scenario.
+
+Compares the rule IDs declared in specs/GroundRules.md against the
+`covers:` lists in scenarios/ground/*.yaml. Exits non-zero when a
+scenario claims a rule the spec does not define, so coverage can never
+be inflated by a typo'd or invented rule ID.
+"""
+import glob
+import re
+import sys
+
+spec = open("specs/GroundRules.md").read()
+rules = sorted(set(re.findall(r"\*\*(GR-[A-Z]+\d+)", spec)))
+
+covered = set()
+for path in sorted(glob.glob("scenarios/ground/*.yaml")):
+ match = re.search(r"covers: \[(.*?)\]", open(path).read(), re.S)
+ if match:
+ covered |= {c.strip() for c in match.group(1).split(",") if c.strip()}
+
+known = set(rules)
+hit = sorted(known & covered)
+missing = [r for r in rules if r not in covered]
+invented = sorted(covered - known)
+
+pct = 100 * len(hit) // len(rules) if rules else 0
+print(f"AM-1 rule coverage: {len(hit)}/{len(rules)} ({pct}%)")
+if missing:
+ print(" uncovered:", " ".join(missing))
+if invented:
+ print(" ERROR — claimed but not defined in the spec:", " ".join(invented))
+ sys.exit(1)
+sys.exit(0 if not missing else 2)
diff --git a/workplans/CB-WP-0001-inner-loop.md b/workplans/CB-WP-0001-inner-loop.md
index 8baed9c..009b2a1 100644
--- a/workplans/CB-WP-0001-inner-loop.md
+++ b/workplans/CB-WP-0001-inner-loop.md
@@ -1,7 +1,7 @@
---
id: CB-WP-0001
title: "Establish the assimilate-and-surpass inner loop via the GROUND game kernel"
-status: active
+status: done
state_hub_workstream_id: "a1b434dc-b1c6-46b5-bbd9-80a4e6b7620f"
---
@@ -161,7 +161,7 @@ the baselines. An empty-but-compiling, measurable loop bed.
```task
id: CB-WP-0001-T08
-status: todo
+status: done
priority: medium
state_hub_task_id: "3a42ff70-f3c6-4e3e-b022-f701729e71ff"
```
@@ -172,11 +172,18 @@ scenarios pass headless, replay is deterministic, and every acceptance
metric meets or beats its recorded baseline — with the comparison numbers
committed as evidence.
+**Outcome:** evidence/CB-EV-0001-game-kernel.md. 21 scenarios pass, AM-1
+rule coverage 58/58, AM-6/AM-7/AM-8/AM-10 met with margin. **AM-4 is not
+met** (33 crates vs ≤20) and is carried into T09 as a decision: make
+serde_yaml optional, or move a target that the spec's own K5/K7
+contracts make unreachable. AM-12 could not be computed honestly because
+per-task token counts were never instrumented — also a T09 input.
+
## Task: Retrospective — harden the loop from what the example taught
```task
id: CB-WP-0001-T09
-status: todo
+status: done
priority: low
state_hub_task_id: "e99b107e-087e-49e1-96b5-67805deb242f"
```
@@ -186,3 +193,11 @@ heavy or too thin, what the survey template missed, what metrics turned out
to matter. Output is InnerLoop v1.0 — the process the next capability
workplan starts from. The loop is only "established" once it has survived
its first full pass and been corrected.
+
+**Outcome:** specs/InnerLoop.md v1.0 and
+history/260731-inner-loop-retrospective.md. The adversarial review, the
+parity cap, the provisional mechanism and the ADR gate held. The gap the
+pass exposed: both serious errors were *measurement* errors and review
+caught neither, because review reads prose and these were claims about
+numbers. v1.0 adds the positive-control rule, metric feasibility and
+instrument naming, and four implementation rules.