diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
deleted file mode 100644
index bf5f62a..0000000
--- a/.claude/ralph-loop.local.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-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 2975860..1cf6521 100644
--- a/Makefile
+++ b/Makefile
@@ -15,9 +15,6 @@ 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 4b7d9df..6484c9c 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::{parse_actor, run, CommandStep, RunOutcome, ScenarioFile, ScenarioGame, Setup};
+pub use scenario::{RunOutcome, ScenarioFile};
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 1a6aa08..5d45cf5 100644
--- a/crates/cb-game-runtime/src/scenario.rs
+++ b/crates/cb-game-runtime/src/scenario.rs
@@ -1,11 +1,9 @@
-//! 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).
+//! 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.
-use cb_events::{state_hash_hex, Envelope, EventLog};
-use cb_kernel::{Actor, Aggregate, EventSeq, GameId, PlayerId};
use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::BTreeMap;
/// One scenario file (`scenarios//.yaml`).
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -73,250 +71,21 @@ 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 },
+ Passed {
+ covers: Vec,
+ },
+ Failed {
+ reason: String,
+ },
+ /// Scaffold state: parsing works, execution lands in T08.
+ 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,
- }
+/// 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
}
#[cfg(test)]
@@ -351,23 +120,7 @@ expect:
assert_eq!(parsed.covers, vec!["GR-S02", "GR-S03"]);
assert_eq!(parsed.setup.players, 3);
assert_eq!(parsed.commands.len(), 1);
- }
-
- #[test]
- fn actors_parse_1_based_into_0_based_seats() {
- assert_eq!(parse_actor("P1").unwrap(), Actor::Player(PlayerId(0)));
- assert_eq!(parse_actor("P3").unwrap(), Actor::Player(PlayerId(2)));
- assert_eq!(parse_actor("SYSTEM").unwrap(), Actor::System);
- assert!(parse_actor("P0").is_err());
- assert!(parse_actor("bogus").is_err());
- }
-
- #[test]
- fn dot_paths_index_objects_and_arrays() {
- let state = serde_json::json!({ "players": { "0": { "stress": 2 } }, "deck": [7, 9] });
- assert_eq!(lookup(&state, "players.0.stress").unwrap(), &2);
- assert_eq!(lookup(&state, "deck.1").unwrap(), &9);
- assert!(lookup(&state, "players.9.stress").is_none());
+ assert!(matches!(run(&parsed), RunOutcome::Unimplemented));
}
#[test]
diff --git a/evidence/CB-EV-0001-game-kernel.md b/evidence/CB-EV-0001-game-kernel.md
deleted file mode 100644
index f69af30..0000000
--- a/evidence/CB-EV-0001-game-kernel.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# 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 8a6d596..b1700b6 100644
--- a/games/ground/benches/synthetic.rs
+++ b/games/ground/benches/synthetic.rs
@@ -1,219 +1,48 @@
-//! 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.
+//! 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.
-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;
+use cb_events::state_hash;
+use cb_game_runtime::CommitWindow;
+use cb_kernel::PlayerId;
+use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
-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,
+/// 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()
}
-/// 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 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)
+ // 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,
+ )
});
}
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 c1d5061..00d211e 100644
--- a/games/ground/src/lib.rs
+++ b/games/ground/src/lib.rs
@@ -1,9 +1,8 @@
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
-//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
-//! comment, giving a greppable rule→code→scenario chain.
+//! GameKernel K15–K16). T07 scaffolds the state shell; validate/fold per
+//! GR-rule land in T08 with rule IDs cross-referenced in doc comments.
-use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
-use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
+use cb_kernel::PlayerId;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -12,12 +11,8 @@ use std::collections::BTreeMap;
pub struct PlayerState {
/// GR-F01: clamped 0–5.
pub stress: u8,
- /// GR-F03: the Freedom token is READY until spent.
+ /// GR-F03.
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,
@@ -66,54 +61,6 @@ 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)]
@@ -122,1788 +69,12 @@ pub struct GroundState {
pub lead: PlayerId,
pub players: BTreeMap,
/// Keyed by (low, high) player pair.
- pub relations: BTreeMap,
+ pub relations: BTreeMap<(PlayerId, PlayerId), Relation>,
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)]
@@ -1920,7 +91,6 @@ mod tests {
PlayerState {
stress: 2,
freedom_ready: true,
- freedom_gate_lifted: false,
darvo: DarvoStage::Off,
hand: vec![SolutionCard { suit: Suit::Repair }],
protection: 0,
@@ -1932,149 +102,9 @@ 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]
@@ -2087,164 +117,3 @@ 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
deleted file mode 100644
index c6c6f8a..0000000
--- a/history/260731-inner-loop-retrospective.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# 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
deleted file mode 100644
index e5de082..0000000
--- a/scenarios/ground/gr-a01-investigate.yaml
+++ /dev/null
@@ -1,43 +0,0 @@
-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
deleted file mode 100644
index 86874f3..0000000
--- a/scenarios/ground/gr-a02-solve.yaml
+++ /dev/null
@@ -1,43 +0,0 @@
-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
deleted file mode 100644
index d70d957..0000000
--- a/scenarios/ground/gr-a04-bond-support.yaml
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index 1208435..0000000
--- a/scenarios/ground/gr-a05-support-response.yaml
+++ /dev/null
@@ -1,57 +0,0 @@
-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
deleted file mode 100644
index b90030b..0000000
--- a/scenarios/ground/gr-a07-bond-flip.yaml
+++ /dev/null
@@ -1,38 +0,0 @@
-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
deleted file mode 100644
index 2ea741f..0000000
--- a/scenarios/ground/gr-a08-rivalry-break.yaml
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index bb93c1a..0000000
--- a/scenarios/ground/gr-a10-ground-gr.yaml
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index 97c2167..0000000
--- a/scenarios/ground/gr-a11-ground-ou.yaml
+++ /dev/null
@@ -1,43 +0,0 @@
-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
deleted file mode 100644
index e363f01..0000000
--- a/scenarios/ground/gr-a12-ground-nd.yaml
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index 19ed4ab..0000000
--- a/scenarios/ground/gr-d01-darvo-trigger.yaml
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index 2ca341a..0000000
--- a/scenarios/ground/gr-d03-darvo-deny.yaml
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index d6e12f8..0000000
--- a/scenarios/ground/gr-d04-darvo-attack.yaml
+++ /dev/null
@@ -1,53 +0,0 @@
-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
deleted file mode 100644
index 77efb18..0000000
--- a/scenarios/ground/gr-d05-darvo-reverse.yaml
+++ /dev/null
@@ -1,54 +0,0 @@
-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
deleted file mode 100644
index a5dd95a..0000000
--- a/scenarios/ground/gr-d06-darvo-early-end.yaml
+++ /dev/null
@@ -1,43 +0,0 @@
-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
deleted file mode 100644
index 8182ef2..0000000
--- a/scenarios/ground/gr-e02-shared-ground.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-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
deleted file mode 100644
index d9771ec..0000000
--- a/scenarios/ground/gr-e04-coalitions.yaml
+++ /dev/null
@@ -1,54 +0,0 @@
-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
deleted file mode 100644
index 105a368..0000000
--- a/scenarios/ground/gr-f02-no-gate.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index 117d9c7..0000000
--- a/scenarios/ground/gr-r02-select.yaml
+++ /dev/null
@@ -1,35 +0,0 @@
-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
deleted file mode 100644
index 7562ff4..0000000
--- a/scenarios/ground/gr-r03-stress-gate.yaml
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 679a40a..0000000
--- a/scenarios/ground/gr-r06-round-resolve.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-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 a5304fc..c665ac3 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-S04, GR-O01, GR-O02]
+covers: [GR-S02, GR-S03, GR-O01]
provisional: false
seed: 42
setup:
@@ -14,10 +14,4 @@ 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 e5c35a2..84b4591 100644
--- a/specs/InnerLoop.md
+++ b/specs/InnerLoop.md
@@ -1,11 +1,9 @@
# The Inner Loop — Assimilate and Surpass
-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`.
+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).
Normative process for building every Clay-Borg capability. Referenced by
all workplans. The loop's own optimization target is **agentic efficiency**:
@@ -44,10 +42,7 @@ 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)`). **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
+(`tier: M (structural L, chaos 10→M)`). 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
@@ -120,18 +115,6 @@ 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:
@@ -146,40 +129,6 @@ 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
@@ -222,33 +171,6 @@ 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:
@@ -280,12 +202,6 @@ 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 633d1aa..d881eca 100644
--- a/tools/cb-sim/src/main.rs
+++ b/tools/cb-sim/src/main.rs
@@ -1,10 +1,9 @@
//! cb-sim — scenario runner binary (GameKernel K17; precursor of `cb sim`).
-//! 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.
+//! 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.
use cb_game_runtime::{scenario, RunOutcome, ScenarioFile};
-use games_ground::GroundState;
fn main() {
let args: Vec = std::env::args().skip(1).collect();
@@ -13,10 +12,8 @@ fn main() {
std::process::exit(64);
}
- let mut unknown_game = false;
+ let mut unimplemented = 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) {
@@ -27,50 +24,36 @@ fn main() {
continue;
}
};
- let sc = match ScenarioFile::from_yaml(&text) {
+ 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 unknown_game {
+ if unimplemented {
std::process::exit(2);
}
}
diff --git a/tools/rule-coverage.py b/tools/rule-coverage.py
deleted file mode 100755
index 306fa99..0000000
--- a/tools/rule-coverage.py
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/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 009b2a1..8baed9c 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: done
+status: active
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: done
+status: todo
priority: medium
state_hub_task_id: "3a42ff70-f3c6-4e3e-b022-f701729e71ff"
```
@@ -172,18 +172,11 @@ 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: done
+status: todo
priority: low
state_hub_task_id: "e99b107e-087e-49e1-96b5-67805deb242f"
```
@@ -193,11 +186,3 @@ 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.