clay-borg/games/ground/src/lib.rs
tegwick 48a7bce04d
Some checks failed
ci / check (push) Has been cancelled
CB-WP-0048 T02/T03/T04: Configuration replaces Variant in the state
The three-armed enum could not express a configuration carrying two
modules. It does now: --variant h1 expands to problem_stress.flat_any_open
AND attack_relief.self_soothe_ge4, and scoped_plus_attack_soothe plays.

Legacy names alias forever via serde(alias="variant") plus a
scalar-or-map deserialiser. serde(default) alone would have been a silent
migration bug -- every H1/H2 recording would have come back as baseline.
All 26 scenarios pass unchanged; none pins a state hash.

Three things shipped broken first, all caught by gates rather than by
reading:

1. `profile` was in the state hash. with_config(select("ground-darvo-r0"))
   sets profile=Some("baseline") where setup alone leaves None, so two
   states at THE SAME POINT IN ASPECT SPACE hashed differently and a
   replay bundle stopped reproducing its own initial state. Now
   serde(skip): a hash covers what determines play. This was the open
   judgement from T01 and it did not survive contact with the replay path.

2. A YAML parse inside the event loop. rules() -> resolve() -> catalog()
   re-parsed catalog.yaml per rule check; AM-6 fell to 9,345 events/s
   against a 100,000 target. OnceLock, and aspect validation moved to
   where a configuration is BUILT.

   Then I nearly optimised a phantom: 470k still looked like a 3x
   regression against the "~1.7M on bnt-lap001" reference in the gate's
   own message. Making rules() free measured 491k -- this machine's
   ceiling. Before optimising against a reference, measure the ceiling
   with the suspect code removed.

3. The refusal did not fire on the path a player takes.
   `--module problem_deal.pressure_deck` played a full baseline game and
   reported success, because with_config is a builder and fell back to
   the printed rules -- the silent no-op ADR-0022 exists to refuse. Every
   unit test of resolve() passed. The helper was tested and the driver
   was not, which is CB-WP-0033's finding verbatim. The new test asserts
   refusal BY NAME and that no game was played.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:54:34 +02:00

4580 lines
187 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
//! GameKernel K15K16). Every rule realized here names its GR-id in a doc
//! comment, giving a greppable rule→code→scenario chain.
/// Bots (CB-WP-0008 T01) — the kernel's first non-scenario consumer.
/// Deliberately **not** gated on `scenarios`: a bot needs only the
/// aggregate. That its tests do need `scenarios` is a real seam — setup
/// presets currently live behind that feature (see `bot.rs`).
pub mod bot;
/// The vendored edition data (ADR-0011, ADR-0015).
///
/// **Not behind `scenarios`.** It was, because its only consumer —
/// `setup` — is; but the edition is the *game's own data*, and since
/// CB-WP-0028 the shipped runtime reads it too, to show a player what a
/// card says. Test machinery and game content are different things and
/// only one of them is optional.
pub mod edition;
/// ground-game's rules catalog, schema 2 (CB-WP-0048, ADR-0022).
///
/// Gated with `scenarios` because it parses YAML and that is where
/// `serde_yaml` lives. The *identity* type in `config` is not gated: a
/// configuration must be readable off a recording whether or not this
/// build can validate it against the catalog.
#[cfg(feature = "scenarios")]
pub mod catalog;
/// A configuration is a point in aspect space (ADR-0022).
#[cfg(feature = "scenarios")]
pub mod config;
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first
/// implementor. Needs the runtime's `Project`, which the game already
/// depends on, so it is not feature-gated either.
pub mod view;
/// The inverse of `parse_command` (CB-WP-0008 T02): a played game becomes
/// a scenario. Needs the scenario vocabulary, so it is gated with it.
#[cfg(feature = "scenarios")]
pub mod record;
/// *Was this deal winnable?* — the retrospective search (CB-WP-0025 T05,
/// ADR-0013). Uses only `validate`/`fold`/`legal_commands`, so it lives
/// beside the aggregate rather than in a crate that would re-export it
/// (ADR-0013 D6).
pub mod search;
#[cfg(feature = "scenarios")]
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// GR-O02: per-player state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerState {
/// GR-F01: clamped 05.
pub stress: u8,
/// GR-F03: the Freedom token is READY until spent.
pub freedom_ready: bool,
/// GR-R03: set when Freedom is spent this round, lifting the stress
/// gate for this Select step only. Cleared at round End.
#[serde(default)]
pub freedom_gate_lifted: bool,
/// GR-D01/D02: OFF or the pending/active stage.
pub darvo: DarvoStage,
pub hand: Vec<SolutionCard>,
pub protection: u8,
/// Blame tokens in front of this player (GR-T02), keyed by owner.
pub blame_from: Vec<PlayerId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DarvoStage {
Off,
Deny,
Attack,
Reverse,
}
/// GR-O04: one Problem card's live state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProblemState {
pub suit: Suit,
pub value: u8,
pub face_up: bool,
pub denied: bool,
pub claimed_by: Option<PlayerId>,
/// GR-A11: protected from Deny this round by GROUND—OU.
pub protected_this_round: bool,
/// H2-OWN: which seat this Problem's Stress falls on, for a
/// `personal` or `bond` scope (CB-WP-0042).
///
/// **`None` under the baseline, and skipped when `None` so it
/// serialises exactly as it always did** — every recorded scenario
/// predates this field, and a state that hashed differently would
/// break all of them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<PlayerId>,
/// H2-SCOPE: who this Problem's End-of-Round Stress falls on.
///
/// Stored rather than looked up, because the lookup needs the
/// `hidden_priority` and `ProblemState` does not carry it — and
/// deriving the priority from the map key would make the deal order
/// load-bearing for a rules effect.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<crate::edition::StressScope>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Suit {
Clarify,
Repair,
Boundary,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SolutionCard {
pub suit: Suit,
}
/// GR-O05: at most one relation per pair; endpoints ordered low→high.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Relation {
Bond,
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Pair {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<u8>()
.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)]
pub struct GroundState {
pub round: u8,
pub lead: PlayerId,
pub players: BTreeMap<PlayerId, PlayerState>,
/// Keyed by (low, high) player pair.
pub relations: BTreeMap<Pair, Relation>,
pub problems: BTreeMap<u32, ProblemState>,
pub solution_deck: Vec<SolutionCard>,
pub solution_discard: Vec<SolutionCard>,
/// Focus placements: sequence owner → target (GR-T03).
pub focus: BTreeMap<PlayerId, PlayerId>,
/// 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<PlayerId, Selection>,
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
/// GR-A11/A12: the sub-choice accompanying an OU or ND mode.
pub ground_choices: BTreeMap<PlayerId, GroundChoice>,
/// GR-L02/A05: a Support target's response, keyed by target.
pub support_responses: BTreeMap<PlayerId, SupportResponse>,
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// GR-E02..E04: which scoring mode this game uses.
pub mode: ScoringMode,
/// Which selectable rules package the kernel is playing
/// (CB-WP-0038, ground-game `editions/catalog.yaml`).
///
/// **In the state, therefore in the hash, therefore in the
/// recording.** A scenario replayed under a different variant would
/// diverge silently, and the recording is what every other artifact
/// rests on. `#[serde(default)]` so every scenario written before
/// variants existed still loads, as baseline — which is what it was.
/// Which point in aspect space this game is played at
/// ([ADR-0022](../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md)).
///
/// **Was `variant: Variant`, a three-armed enum** — which could not
/// express `problem_stress.scoped` × `attack_relief.self_soothe_ge4`,
/// a profile the catalog ships. Not "unimplemented": unrepresentable.
///
/// `alias = "variant"` plus `Configuration`'s scalar-or-map
/// deserialiser means a recording written before this loads by its
/// old name and old spelling. **`serde(default)` alone would have
/// been a silent migration bug**: every recording played under H1 or
/// H2 would have come back as the baseline.
#[serde(default, alias = "variant")]
pub config: crate::config::Configuration,
/// Which of the edition's four Scenarios is on the table
/// (CB-WP-0047).
///
/// **The engine dealt `SCN_01` and only `SCN_01`** for the whole life
/// of this repo — the other three decks were vendored, gated, and
/// never played. Fifteen of the twenty Problem cards had never
/// reached a table.
///
/// In the state for the same reason `variant` is: the threshold and
/// the whole board follow from it, so a recording that did not carry
/// it could not be replayed. `#[serde(default)]` returns `SCN_01`,
/// which is what every recording written before this field existed
/// actually played.
#[serde(default = "default_scenario")]
pub scenario: String,
/// GR-R09: set once the game has ended and scoring has run.
pub outcome: Option<Outcome>,
/// 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-E01 by seat band, as the engine used to hardcode it.
///
/// **Kept as the fallback, and only as the fallback.** All four
/// scenarios print 5/7/9, so this and the edition agree today — which is
/// exactly why a test comparing the two numbers proves nothing. See
/// `threshold_from`.
fn seat_band_threshold(seats: usize) -> u32 {
match seats {
0..=2 => 5,
3..=4 => 7,
_ => 9,
}
}
/// The threshold a Scenario card states, or the seat band if the edition
/// does not have that card (CB-WP-0047).
///
/// **Split out so it can be tested against a card that disagrees.** The
/// first version read the edition inline, and mutating it back to the
/// hardcoded bands left every test green — because all four scenarios
/// print 5/7/9 and the two paths are observationally identical on every
/// input the edition can supply. A control that cannot separate the
/// thing it is about from its fallback is not a control; taking a list
/// as an argument lets one be written.
#[cfg(feature = "scenarios")]
fn threshold_from(list: &[crate::edition::ScenarioText], scenario: &str, seats: usize) -> u32 {
match list.iter().find(|s| s.id == scenario) {
Some(s) => {
let (two, three_four, five_six) = s.thresholds;
match seats {
0..=2 => two,
3..=4 => three_four,
_ => five_six,
}
}
None => seat_band_threshold(seats),
}
}
/// The scenario every recording written before CB-WP-0047 played.
///
/// **Not "the first scenario" — the one that was actually dealt.** The
/// distinction matters if the edition ever reorders `Scenarios.csv`.
fn default_scenario() -> String {
"SCN_01".to_string()
}
/// 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,
}
/// A selectable rules package (ground-game `editions/catalog.yaml`).
///
/// **Not a difficulty setting and not a preference.** A variant changes
/// what the rules *are*, so unlike `Pace` it legitimately changes the
/// outcome, the state hash and the recording — and must therefore be
/// recorded with the game rather than chosen at render time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Variant {
/// `ground-darvo-r0` — the printed baseline, and the default.
#[default]
Baseline,
/// `h2-scoped-problem-stress` — unclaimed Problems tick only the
/// seats in their scope. **Does not stack with H1** — the package
/// says `replaces_experiments: [h1-problem-stress]`.
H2ScopedProblemStress,
/// `h1-problem-stress` — ground-game's hypothesis H1.
///
/// **Experimental.** Two deltas only: unclaimed Problems raise
/// everyone's Stress at Round End, and a high-Stress attacker gets a
/// small self-relief.
H1ProblemStress,
}
impl Variant {
/// This legacy name as a **configuration** (ADR-0022 D2).
///
/// The enum survives as a spelling the maintainer types and every
/// recording carries; it is no longer what the kernel branches on.
/// Expansion goes through the catalog's own `legacy_experiment_id`,
/// so ground-game's mapping has one home.
pub fn configuration(self) -> crate::config::Configuration {
crate::config::Configuration::select(self.id()).unwrap_or_default()
}
/// The catalog's `variant_id`, which is how ground-game names these.
pub fn id(self) -> &'static str {
match self {
Variant::Baseline => "ground-darvo-r0",
Variant::H1ProblemStress => "h1-problem-stress",
Variant::H2ScopedProblemStress => "h2-scoped-problem-stress",
}
}
}
impl std::str::FromStr for Variant {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
"h2-scoped-problem-stress" | "h2" => Ok(Variant::H2ScopedProblemStress),
other => Err(format!(
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress, \
h2-scoped-problem-stress)"
)),
}
}
}
impl GroundState {
/// Select the rules package, **and apply whatever its setup needs**
/// (CB-WP-0042 T02).
///
/// **A builder rather than a bare field write.** H2 assigns Problem
/// owners at setup, and `state.variant = v` would leave them
/// unassigned — a silently wrong game rather than a failing one.
/// Every driver path goes through here.
pub fn with_variant(self, variant: Variant) -> Self {
self.with_config(variant.configuration())
}
/// Select the configuration, and apply whatever its modules need at
/// setup (ADR-0022 D3).
///
/// **A builder rather than a bare field write.**
/// `problem_stress.scoped` assigns Problem owners at setup, and
/// `state.config = c` would leave them unassigned — a silently wrong
/// game rather than a failing one. That defect had three call sites
/// under the old enum; with N aspects it is N times as likely, so
/// every driver path goes through here.
pub fn with_config(mut self, config: crate::config::Configuration) -> Self {
let rules = config.resolve();
self.config = config;
if let Ok(r) = rules {
if r.problem_stress == crate::config::ProblemStress::Scoped {
self.apply_h2_setup();
}
}
self
}
/// What this game's configuration means for the kernel.
///
/// **Falls back to the printed rules** when the configuration names
/// something this kernel cannot run. `setup` and `with_config` are
/// where that is refused loudly; by the time a rule is being applied
/// the game is already in progress, and a mid-game panic would turn
/// a selection error into a crash at an unrelated moment.
fn rules(&self) -> crate::config::Rules {
self.config.resolve().unwrap_or_default()
}
/// H2-SCOPE and H2-OWN, applied to the dealt board.
///
/// Owners go to the **non-global** Problems in **ascending hidden
/// priority**, starting at the Lead and stepping clockwise. The
/// Problems map is keyed 1..n in deal order and `edition::deal`
/// sorts by priority, so map order *is* priority order — asserted in
/// `owners_follow_ascending_priority_from_the_lead` rather than
/// assumed.
fn apply_h2_setup(&mut self) {
let Ok(scopes) = crate::edition::stress_scopes("SCN_01") else {
return;
};
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
let mut next = 0usize;
// Keys ascend with priority; priority 0 is the Surface.
for (i, (_, problem)) in self.problems.iter_mut().enumerate() {
let priority = i as u8;
let scope = scopes.get(&priority).copied();
problem.scope = scope;
if matches!(
scope,
Some(crate::edition::StressScope::Personal)
| Some(crate::edition::StressScope::Bond)
) {
problem.owner = Some(seats[(start + next) % seats.len()]);
next += 1;
}
}
}
}
/// 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<PlayerId, i32>,
/// GR-E04: each Bond-connected group and its combined score.
pub coalitions: Vec<Coalition>,
/// GR-E02: only meaningful in SHARED GROUND.
pub mastery: Option<i32>,
pub winners: Vec<PlayerId>,
}
/// GR-E04: one Bond-connected group. Unbonded players are solo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coalition {
pub members: Vec<PlayerId>,
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<u32>,
pub player: Option<PlayerId>,
}
/// 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,
}
}
#[cfg(feature = "scenarios")]
fn parse(raw: &str, arg: Option<u64>) -> Result<Self, String> {
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 {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
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 {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
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<PlayerId>,
pub problem: Option<u32>,
}
/// 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 {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
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<PlayerId>,
problem: Option<u32>,
},
/// 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<GroundChoice>,
},
/// 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<GroundChoice>,
},
/// 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<SolutionCard>,
},
/// 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 45 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
}
/// H2-A: the owner plus every seat reachable over **Bond edges
/// only** — never Rivalry. A degree-0 owner falls back to personal,
/// which the delta states and which is the branch a test forgets.
fn bond_network(&self, owner: PlayerId) -> Vec<PlayerId> {
let mut seen = vec![owner];
let mut frontier = vec![owner];
while let Some(seat) = frontier.pop() {
for (pair, rel) in &self.relations {
if *rel != Relation::Bond || !pair.contains(seat) {
continue;
}
let other = if pair.0 == seat { pair.1 } else { pair.0 };
if !seen.contains(&other) {
seen.push(other);
frontier.push(other);
}
}
}
seen
}
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
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<PlayerId> {
let seats: Vec<PlayerId> = 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 05.
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<Vec<Self::Event>, 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<PlayerId> = 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 45 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.
///
/// Steps run: GROUND (GR-A10..A12), Support (GR-A03..A05), Attack
/// (GR-A06), DARVO (GR-D02..D07), INVESTIGATE (GR-A01), SOLVE
/// (GR-A02).
///
/// **This comment claimed DARVO and GROUND—OU/ND were "not yet
/// implemented" until 2026-08-01**, long after both landed with their
/// scenarios (`gr-d01`…`gr-d06`, `gr-a11`, `gr-a12`). Nothing checks
/// prose against code, which is the DFD class `make facts-check`
/// gates for *numbers* and cannot gate for claims like this one.
fn resolution_events(&self) -> Vec<GroundEvent> {
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<GroundEvent>,
) {
// H1-B (CB-WP-0038): read BEFORE anything resolves. The delta
// says "the attacker's Stress was >= 4 **before this Attack's
// effects**", and the attacker's own Stress can move during
// resolution -- so capturing it afterwards would answer a
// different question.
let attacker_stress_before = self.players.get(&attacker).map_or(0, |p| p.stress);
// 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"));
}
// H1-B: the self-soothe, **after target and relation effects**
// (`order: after_target_and_relation_effects`) and only on an
// Attack that actually resolved -- both cancel paths above have
// already returned.
//
// The DARVO extra Attack comes through here too, which the delta
// requires: "DARVO-stage extra Attack uses the same Attack
// resolution (so it can self-soothe too if Stress >= 4)".
if self.rules().attack_relief == crate::config::AttackRelief::SelfSootheGe4
&& attacker_stress_before >= 4
{
let stress = self.stress_after(attacker, -1);
events.push(GroundEvent::StressSet {
player: attacker,
stress,
});
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<GroundEvent>) {
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<GroundEvent> {
let mut events = Vec::new();
// H1-A (CB-WP-0038): problem pressure, and it lands BEFORE the
// DARVO arm check below, because ground-game's delta orders it
// "+1 Stress, then clamp 0-5, then DARVO arm check as today".
// Applying it after would make the pressure unable to arm
// anything for a round -- the opposite of the hypothesis.
//
// **"Unclaimed" includes Denied and still-hidden Problems**, and
// that is the clause a careless reading drops: it is `claimed_by
// .is_none()`, not "face-up and unsolved".
//
// The trigger loop reads `work`, so it sees the new Stress.
let mut work = self.clone();
// H2-A (CB-WP-0042): scoped pressure. Each unclaimed Problem
// ticks only the seats in ITS scope, and `stacking: true` — two
// open bond Problems hit the network twice, so this accumulates
// per Problem rather than applying once.
if self.rules().problem_stress == crate::config::ProblemStress::Scoped {
let mut ticks: BTreeMap<PlayerId, i16> = BTreeMap::new();
for problem in self.problems.values() {
if problem.claimed_by.is_some() {
continue;
}
let recipients: Vec<PlayerId> = match problem.scope {
Some(crate::edition::StressScope::Global) => {
self.players.keys().copied().collect()
}
Some(crate::edition::StressScope::Personal) => {
problem.owner.into_iter().collect()
}
Some(crate::edition::StressScope::Bond) => match problem.owner {
Some(owner) => self.bond_network(owner),
None => vec![],
},
None => vec![],
};
for seat in recipients {
*ticks.entry(seat).or_default() += 1;
}
}
// In seat order, so two seats taking a tick are ordered (U9).
for seat in self.seat_order() {
let Some(n) = ticks.get(&seat).copied() else {
continue;
};
let stress = work.stress_after(seat, n);
let e = GroundEvent::StressSet {
player: seat,
stress,
};
work.fold(&e);
events.push(e);
}
}
if self.rules().problem_stress == crate::config::ProblemStress::FlatAnyOpen
&& self.problems.values().any(|p| p.claimed_by.is_none())
{
for seat in self.seat_order() {
// `stress_after` clamps 0..=5, which is the delta's
// "then clamp".
let stress = work.stress_after(seat, 1);
let e = GroundEvent::StressSet {
player: seat,
stress,
};
work.fold(&e);
events.push(e);
}
}
// GR-D01: Stress 5 with the marker OFF starts a sequence. In
// Lead order, so two simultaneous triggers are ordered (U9).
for seat in work.seat_order() {
let player = &work.players[&seat];
if player.stress == 5 && player.darvo == DarvoStage::Off {
events.push(GroundEvent::DarvoTriggered { player: seat });
}
}
let seats: Vec<PlayerId> = 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.
// **Scored from `work`, not `self`** (CB-REV-0001 #13). H1-A's
// pressure goes into `work` above, and `score` reads Stress for
// the GR-E03 and GR-E04 tiebreaks — so scoring from `self` made
// the final round's pressure invisible to exactly the two modes
// CB-EV-0030 reports on. Under the baseline `work` is a clone and
// this is a no-op.
if self.round >= 5 {
events.push(GroundEvent::GameEnded {
outcome: work.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, **off the Scenario card**.
///
/// This was `match seats { 0..=2 => 5, 3..=4 => 7, _ => 9 }` — the
/// engine's own copy of a number four cards already print, which is
/// exactly what F25 was raised about. It was right only because all
/// four scenarios happen to agree, and a fifth scenario with a
/// different threshold would have been scored against the old one
/// with nothing to say so.
///
/// The bands survive as the fallback for a state whose scenario the
/// edition cannot supply. **That path is unreachable through
/// `setup`**, which refuses an unknown scenario before dealing, and
/// a test holds it unreachable — a fallback nothing can reach is the
/// only kind that cannot silently answer for the real one.
#[cfg(feature = "scenarios")]
fn threshold(&self) -> u32 {
let list = crate::edition::scenarios().unwrap_or_default();
threshold_from(&list, &self.scenario, self.players.len())
}
/// Without the `scenarios` feature there is no edition to read, so
/// the seat bands are the whole answer rather than a fallback.
#[cfg(not(feature = "scenarios"))]
fn threshold(&self) -> u32 {
seat_band_threshold(self.players.len())
}
/// GR-E01..E04: final scoring for the configured mode.
/// **Public since CB-WP-0049 T02.** A policy needs to know what its
/// seat is trying to maximise, and the game already computes all
/// three answers here. A second copy in the bot would be a second
/// definition of winning — and the two would disagree the first time
/// ground-game rules on F28's mastery reading.
///
/// It is a pure function of the state, so it answers mid-game too:
/// "what would this position score if it stopped now".
pub 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<PlayerId, i32> = 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;
// **Points, not card count** — F28, ruled by ground-game
// 2026-08-08 and clarified in `Modes.csv`: *"start from
// that shared point total and subtract 1 point for each
// Blame token still in play and 1 point for each Denied
// Problem (penalties apply to points, not card count)."*
//
// This counted CLAIMED CARDS. Both readings fit the
// sentence as it stood, and they differ on every game
// where a 3-point Problem is claimed — which is most of
// them. Reported rather than guessed; the card is the
// authority (ADR-0015) and it now says so outright.
let mastery = total as i32 - 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<PlayerId> = 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<T: Copy, K: Ord>(&self, candidates: Vec<T>, key: impl Fn(T) -> K) -> Vec<T> {
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<PlayerId, i32>) -> Vec<Coalition> {
let mut remaining: Vec<PlayerId> = 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<PlayerId> = 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<GroundChoice>,
) -> 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<PlayerId>,
problem: Option<u32>,
) -> 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"
)));
}
// GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal
// only where it can do something. This lives in `validate`
// and not only in `legal_commands` because a rule enforced
// by the offer alone is enforced only for clients that ask
// what is legal — the browser would be filtered and a
// scenario file would not.
Action::Solve if target.claimed_by.is_some() => {
return Err(bad(format!(
"GR-P05: Problem {problem} was claimed in an earlier round"
)));
}
Action::Solve
if !self
.players
.get(&actor)
.is_some_and(|p| p.hand.iter().any(|c| c.suit == target.suit)) =>
{
return Err(bad(format!(
"GR-P05: no {:?} Solution in hand for Problem {problem}",
target.suit
)));
}
_ => {}
}
} else if problem.is_some() {
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
}
Ok(())
}
}
// GR-S01's deal now lives in `edition::hidden_depth` (ADR-0011): the
// ruled shape is Surface + hidden 1..=k, and k belongs beside the data
// it indexes into.
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
// beside the Problem data it is dealt against.
/// Which Scenario a `Setup` preset names, and for how many seats
/// (CB-WP-0047).
///
/// | preset | scenario |
/// |---|---|
/// | `standard-4p` | `SCN_01` |
/// | `scn-03-4p` | `SCN_03` |
///
/// **`standard-Np` keeps meaning exactly what it meant**, which is not a
/// convenience: twenty-six recorded scenarios name it, and a grammar
/// that redefined it would have moved every one of their boards while
/// their hashes still claimed to pin them (ADR-0019's discipline, and
/// the reason `serde(default)` on the field returns `SCN_01`).
///
/// The seat count is checked here rather than after the deal, because
/// `deal` refuses an unknown scenario and a preset naming the wrong seat
/// count would otherwise be reported as a scenario problem.
#[cfg(feature = "scenarios")]
fn parse_preset(preset: &str, seats: u8) -> Result<String, String> {
let suffix = format!("-{seats}p");
let Some(head) = preset.strip_suffix(&suffix) else {
return Err(format!(
"preset {preset:?} does not match {seats} players \
(expected {:?} or e.g. {:?})",
format!("standard{suffix}"),
format!("scn-02{suffix}"),
));
};
let id = match head {
"standard" => default_scenario(),
other => {
let n = other.strip_prefix("scn-").ok_or_else(|| {
format!("preset {preset:?}: expected \"standard\" or \"scn-0N\", got {other:?}")
})?;
format!("SCN_{n}")
}
};
// **Checked against the edition, not against a pattern.** `SCN_09`
// matches the shape and is not a scenario; dealing it would fail
// later with a message about Problems rather than about the preset.
let known = crate::edition::scenarios()?;
if !known.iter().any(|s| s.id == id) {
return Err(format!(
"preset {preset:?} names {id}, which the edition does not have \
(it has {})",
known
.iter()
.map(|s| s.id.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
Ok(id)
}
#[cfg(feature = "scenarios")]
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<Self, String> {
let seats = setup.players;
let scenario = parse_preset(&setup.preset, seats)?;
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
// priorities 1..=k. The values and suits come from the edition
// (ADR-0011); the engine used to invent both. **And now the
// scenario does too** — this argument was the literal `"SCN_01"`
// for the whole life of the repo (CB-WP-0047).
let dealt = crate::edition::deal(&scenario, seats)?;
let mut rng = ChaChaRng::from_seed(Seed(seed));
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
let mut deck = crate::edition::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![],
},
);
}
// Surface is dealt face up; hidden Problems face down (GR-S01).
// Keyed by 1-based position so scenario dot-paths stay stable.
let problems: BTreeMap<u32, ProblemState> = dealt
.iter()
.enumerate()
.map(|(i, p)| {
(
(i + 1) as u32,
ProblemState {
suit: p.suit,
value: p.value,
face_up: p.surface,
denied: false,
claimed_by: None,
protected_this_round: false,
owner: None,
scope: None,
},
)
})
.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,
scenario,
// Baseline. The driver overwrites this after setup and
// before the hash is taken, which is the route `mode` uses
// (`table.rs`) — so a recorded session replays under the
// variant it was played under.
config: crate::config::Configuration::default(),
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<String, String> {
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<u64> { 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)]
mod tests {
/// CB-WP-0038 T02 — the H1 deltas, and ground-game's own claim about
/// what they leave alone.
mod h1 {
use super::super::*;
use cb_game_runtime::{ScenarioGame, Setup};
fn setup(players: u8, variant: Variant, seed: u64) -> GroundState {
let mut s = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
)
.expect("setup");
s = s.with_variant(variant);
s
}
/// **The load-bearing control**, rewritten after review found the
/// first version inert.
///
/// It compared two states built by the *same* `setup` call, both
/// then assigned `Variant::Baseline` — bitwise identical by
/// construction, so the assertion could only fail if hashing were
/// nondeterministic. It carried no information about variants at
/// all. Adding `#[serde(skip)]` to `variant` — which is exactly
/// the "a scenario replayed under the wrong variant diverges
/// silently" failure the workplan named — left all 57 tests green.
///
/// This asserts the two properties that were claimed:
/// **the variant reaches the hash**, and **selecting the baseline
/// is not a change**.
#[test]
fn the_variant_reaches_the_hash_and_baseline_is_not_a_change() {
for players in [2u8, 3, 6] {
for seed in 0..8u64 {
let base = setup(players, Variant::Baseline, seed);
let h1 = setup(players, Variant::H1ProblemStress, seed);
// 1. The variant is IN the hash. `#[serde(skip)]` on
// the field makes these equal, which is the whole
// silent-divergence failure.
assert_ne!(
cb_events::state_hash_hex(&base),
cb_events::state_hash_hex(&h1),
"{players}p seed {seed}: the variant does not reach the state hash, \
so a recording cannot say which rules it was played under"
);
// 2. And selecting the baseline explicitly is not a
// change from selecting nothing.
let untouched = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
)
.expect("setup");
assert_eq!(
untouched.config,
crate::config::Configuration::default(),
"the default moved"
);
assert_eq!(
cb_events::state_hash_hex(&base),
cb_events::state_hash_hex(&untouched),
"{players}p seed {seed}: selecting the baseline changed it"
);
}
}
}
/// **The hazard `with_variant` exists for, made visible.**
///
/// Setting `state.variant = H2` directly compiles and produces a
/// game where **no Problem has a scope**, so scoped pressure ticks
/// nobody and H2 measures as inert — a silently wrong result, not
/// a failure.
///
/// Three call sites had the bare write when this was found, one of
/// them the driver. This test states the difference so a
/// regression is caught by a name rather than by a puzzled reader
/// wondering why H2 did nothing.
#[test]
fn a_bare_variant_write_leaves_h2_inert() {
let base = GroundState::setup(
&Setup {
players: 4,
preset: "standard-4p".into(),
patch: Default::default(),
},
5,
)
.expect("setup");
// The wrong way.
let mut bare = base.clone();
bare.config = Variant::H2ScopedProblemStress.configuration();
assert!(
bare.problems.values().all(|p| p.scope.is_none()),
"a bare write should leave scopes unassigned — if it does not, \
`with_variant` is no longer load-bearing and this test is stale"
);
assert!(
!bare
.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"a bare-write H2 produced pressure, so the hazard has changed shape"
);
// The right way.
let built = base.with_variant(Variant::H2ScopedProblemStress);
assert!(
built.problems.values().all(|p| p.scope.is_some()),
"`with_variant` did not assign scopes"
);
assert!(
built
.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"`with_variant` H2 produced no pressure with Problems unclaimed"
);
}
/// **The baseline is untouched by H2's new state** (T02).
///
/// `owner` and `scope` are new fields on `ProblemState`. Both are
/// `None` under the baseline and skipped when `None`, so a
/// baseline state must serialise and hash exactly as it did
/// before they existed — **every recorded scenario predates
/// them.**
#[test]
fn h2s_new_state_is_invisible_under_the_baseline() {
for players in [2u8, 3, 6] {
let s = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
5,
)
.expect("setup")
.with_variant(Variant::Baseline);
assert!(
s.problems
.values()
.all(|p| p.owner.is_none() && p.scope.is_none()),
"{players}p: the baseline acquired owners or scopes"
);
let json = serde_json::to_string(&s).expect("json");
assert!(
!json.contains("\"owner\"") && !json.contains("\"scope\""),
"{players}p: the baseline serialises H2's fields, so every \
recorded scenario's hash has moved"
);
}
}
/// **H2-OWN: ascending priority, from the Lead, clockwise** (T02).
#[test]
fn owners_follow_ascending_priority_from_the_lead() {
for players in [2u8, 3, 4, 5, 6] {
let s = setup(players, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let start = seats.iter().position(|x| *x == s.lead).expect("lead seats");
// The Surface is global and owns nothing; the rest are
// owned in map order, which is priority order.
let mut expected = 0usize;
for (key, p) in &s.problems {
match p.scope {
Some(crate::edition::StressScope::Global) => {
assert!(
p.owner.is_none(),
"{players}p: a global Problem has an owner"
);
assert_eq!(*key, 1, "the Surface should be the first key");
}
Some(_) => {
assert_eq!(
p.owner,
Some(seats[(start + expected) % seats.len()]),
"{players}p problem {key}: owner is not the next seat \
clockwise from the Lead"
);
expected += 1;
}
None => panic!("{players}p problem {key}: H2 left a Problem unscoped"),
}
}
assert!(expected > 0, "{players}p: nothing was owned");
// Exactly one owner each, and the walk did not skip.
let owned = s.problems.values().filter(|p| p.owner.is_some()).count();
assert_eq!(owned, expected, "{players}p: an owner was assigned twice");
}
}
/// **Deterministic, as the delta requires** — "deterministic for
/// sims". A seeded-but-unstated order would be untestable.
#[test]
fn h2_ownership_is_deterministic() {
for seed in [1u64, 7, 99] {
let a = setup(4, Variant::H2ScopedProblemStress, seed);
let b = setup(4, Variant::H2ScopedProblemStress, seed);
let owners = |s: &GroundState| -> Vec<Option<PlayerId>> {
s.problems.values().map(|p| p.owner).collect()
};
assert_eq!(
owners(&a),
owners(&b),
"seed {seed}: ownership is not stable"
);
}
}
/// **H2-A: each scope reaches exactly who it should** (T03).
///
/// Each case is built so one scope is unclaimed and the rest are
/// claimed, so the recipients are unambiguous.
#[test]
fn h2_pressure_reaches_only_the_scope() {
use crate::edition::StressScope;
let ticks = |scope: StressScope, bonds: &[(usize, usize)]| {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
for (a, b) in bonds {
s.relations
.insert(Pair::new(seats[*a], seats[*b]), Relation::Bond);
}
// Claim everything, then re-open exactly one Problem of
// the scope under test — with a known owner, seat 0.
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(scope))
.unwrap_or_else(|| panic!("no {scope:?} Problem in the deal"));
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let mut out: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
out.sort();
(seats, out)
};
// global: everyone.
let (seats, out) = ticks(StressScope::Global, &[]);
assert_eq!(out.len(), seats.len(), "global did not reach every seat");
// personal: the owner alone.
let (seats, out) = ticks(StressScope::Personal, &[]);
assert_eq!(
out.len(),
1,
"personal reached more than the owner: {out:?}"
);
assert_eq!(out[0].0, seats[0]);
// bond: the owner's network, over BOND edges only.
let (seats, out) = ticks(StressScope::Bond, &[(0, 1), (1, 2)]);
let hit: Vec<PlayerId> = out.iter().map(|(p, _)| *p).collect();
assert!(
hit.contains(&seats[0]) && hit.contains(&seats[1]) && hit.contains(&seats[2]),
"bond did not traverse the network: {hit:?}"
);
assert!(
!hit.contains(&seats[3]),
"bond reached a seat outside the network: {hit:?}"
);
}
/// **Rivalry edges are not the network** (T03) — the single most
/// likely defect, named in the workplan before the code existed.
#[test]
fn h2_bond_scope_does_not_traverse_rivalry() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// 0—1 Bond, 1—2 RIVALRY. The network is {0,1}, never 2.
s.relations
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
s.relations
.insert(Pair::new(seats[1], seats[2]), Relation::Rivalry);
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert!(
!hit.contains(&seats[2]),
"the Bond network crossed a Rivalry edge: {hit:?}"
);
}
/// **A degree-0 owner falls back to personal** (T03) — the branch
/// a test forgets, named in advance.
#[test]
fn h2_a_bond_problem_with_no_bonds_is_personal() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
assert!(s.relations.is_empty(), "the fixture needs no relations");
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert_eq!(hit, vec![seats[0]], "a bondless owner should tick alone");
}
/// **`stacking: true`** (T03): two open Problems hitting the same
/// seat tick it **twice**. A careless implementation applies the
/// pressure once.
#[test]
fn h2_pressure_stacks_per_problem() {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = Some(seats[0]);
}
// Two personal Problems, both owned by seat 0, both open.
let personal: Vec<u32> = keys
.iter()
.copied()
.filter(|k| s.problems[k].scope == Some(crate::edition::StressScope::Personal))
.take(2)
.collect();
assert_eq!(personal.len(), 2, "the deal needs two personal Problems");
for k in &personal {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
}
s.players.get_mut(&seats[0]).expect("s").stress = 0;
let got: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
assert_eq!(
got,
vec![(seats[0], 2)],
"two open Problems on one seat must tick it twice, not once"
);
}
/// **The baseline and H1 do not feel H2-A** (T03).
#[test]
fn h2_pressure_does_not_reach_the_other_variants() {
for variant in [Variant::Baseline, Variant::H1ProblemStress] {
let mut s = setup(3, variant, 5);
// H1 would fire on an unclaimed Problem; claim them all so
// only H2-A could produce anything here.
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"{variant:?} gained scoped pressure"
);
}
}
/// **H2-SOLVE: ownership is not a permission** (T04).
///
/// *"Any seat with a matching suit may solve; the owner need not
/// be the solver"* — and altruistic clearing is **intended**,
/// especially on bond-scope cards.
///
/// **This is a regression test, not a feature.** The engine had
/// no owner concept, so it was already true; T02 added ownership,
/// and the risk is that ownership silently becomes a right.
#[test]
fn h2_ownership_does_not_restrict_who_may_solve() {
let s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// A Problem owned by someone else, face up and unclaimed.
let (key, owner) = s
.problems
.iter()
.find_map(|(k, p)| p.owner.map(|o| (*k, o)))
.expect("H2 assigns owners");
let mut s = s;
s.problems.get_mut(&key).expect("p").face_up = true;
let suit = s.problems[&key].suit;
for seat in &seats {
// Give every seat the matching card.
s.players.get_mut(seat).expect("p").hand = vec![SolutionCard { suit }];
}
for seat in &seats {
let legal = crate::bot::legal_commands(&s, *seat);
let can_solve = legal.iter().any(|c| {
matches!(
c,
GroundCommand::SelectAction {
action: Action::Solve,
problem: Some(p),
..
} if *p == key
)
});
assert!(
can_solve,
"{seat:?} holds the matching suit but may not SOLVE problem {key} \
(owned by {owner:?}) — ownership has become a permission"
);
}
}
/// **H1-A.** Unclaimed Problems raise everyone's Stress at Round
/// End — and "unclaimed" includes Denied and still-hidden, which
/// is the clause a careless reading drops.
#[test]
fn h1a_pressure_applies_while_any_problem_is_unclaimed() {
let mut s = setup(3, Variant::H1ProblemStress, 7);
// A fresh deal has unclaimed Problems by construction.
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
// One hidden, one Denied: neither is "face-up unsolved", and
// both must still count.
let ids: Vec<u32> = s.problems.keys().copied().collect();
s.problems.get_mut(&ids[0]).expect("p").face_up = false;
s.problems.get_mut(&ids[1]).expect("p").denied = true;
let before: Vec<u8> = s.players.values().map(|p| p.stress).collect();
let events = s.end_round_events();
let bumped: Vec<&GroundEvent> = events
.iter()
.filter(|e| matches!(e, GroundEvent::StressSet { .. }))
.collect();
assert_eq!(
bumped.len(),
s.players.len(),
"every player takes the pressure, not just some"
);
for e in bumped {
if let GroundEvent::StressSet { player, stress } = e {
let was = s.players[player].stress;
assert_eq!(*stress, (was + 1).min(5), "clamped 0..=5");
}
}
let _ = before;
}
/// No unclaimed Problem, no pressure — the `when` clause is a
/// condition, not decoration.
#[test]
fn h1a_is_silent_once_every_problem_is_claimed() {
let mut s = setup(3, Variant::H1ProblemStress, 7);
let me = *s.players.keys().next().expect("seat");
for p in s.problems.values_mut() {
p.claimed_by = Some(me);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"pressure applied with nothing left unclaimed"
);
}
/// **The baseline must not feel H1-A at all.**
#[test]
fn h1a_does_not_touch_the_baseline() {
let s = setup(3, Variant::Baseline, 7);
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"the baseline gained problem pressure"
);
}
fn attack(variant: Variant, attacker_stress: u8, protect_target: bool) -> Vec<GroundEvent> {
let mut s = setup(3, variant, 3);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let (a, t) = (seats[0], seats[1]);
s.players.get_mut(&a).expect("a").stress = attacker_stress;
s.players.get_mut(&t).expect("t").protection = u8::from(protect_target);
let mut events = Vec::new();
s.resolve_attack(a, t, &Default::default(), &mut events);
events
.into_iter()
.filter(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a))
.collect()
}
/// **H1-B.** A high-Stress attacker who actually lands an Attack
/// gets a small self-relief.
#[test]
fn h1b_soothes_only_a_landed_attack_from_high_stress() {
// Stress 4, uncancelled: soothed.
let soothed = attack(Variant::H1ProblemStress, 4, false);
assert_eq!(soothed.len(), 1, "no self-soothe at Stress 4");
if let GroundEvent::StressSet { stress, .. } = soothed[0] {
assert_eq!(stress, 3, "the delta is -1");
}
// Below the threshold: nothing.
assert!(
attack(Variant::H1ProblemStress, 3, false).is_empty(),
"soothed below Stress 4"
);
// Cancelled by Protection: nothing. "Not cancelled" is a
// condition of the delta, and Protection is a cancel path.
assert!(
attack(Variant::H1ProblemStress, 4, true).is_empty(),
"a cancelled Attack still soothed the attacker"
);
// And the baseline never soothes.
assert!(
attack(Variant::Baseline, 4, false).is_empty(),
"the baseline gained the self-soothe"
);
}
/// **Commit/reveal IS the extensive-form encoding of simultaneous
/// moves** (CB-WP-0041 T03), and this checks it rather than
/// asserting it.
///
/// An EFG has no simultaneity: the textbook encoding sequences
/// the moves and puts the later mover in an information set that
/// cannot see the earlier one. GROUND's Select step does exactly
/// that — seats choose in order, and no seat may see another's
/// selection until Reveal.
///
/// **The property is load-bearing.** If a seat can see another's
/// pending selection, the encoding is not simultaneous, the
/// information partition is wrong, and every equilibrium concept
/// computed on it answers a different game.
#[test]
fn a_pending_selection_is_hidden_until_reveal() {
use cb_game_runtime::{Project, Viewer};
let mut s = setup(4, Variant::Baseline, 9);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// Every seat commits.
for seat in &seats {
s.selections.insert(
*seat,
Selection {
action: Action::Investigate,
target: None,
problem: s.problems.keys().next().copied(),
},
);
}
// Before Reveal: a seat sees its own and nobody else's.
s.step = RoundStep::Select;
for viewer in &seats {
let v = s.project(Viewer::Player(*viewer));
for (seat, shown) in &v.selections {
let visible = matches!(shown, crate::view::SelectionView::Shown(_));
assert_eq!(
visible,
seat == viewer,
"before Reveal, {viewer:?} could see {seat:?}'s selection — \
the Select step is not simultaneous and the information \
partition is wrong"
);
}
}
// After Reveal: public, which is the other half of the
// encoding — the information sets must MERGE, or the reveal
// never happened.
s.step = RoundStep::Reveal;
for viewer in &seats {
let v = s.project(Viewer::Player(*viewer));
assert!(
v.selections
.values()
.all(|x| matches!(x, crate::view::SelectionView::Shown(_))),
"after Reveal, {viewer:?} still cannot see every selection"
);
}
}
/// **All chance derives from the root seed** (CB-WP-0041 T02).
///
/// The claim T02 rests on, made checkable rather than asserted:
/// a game is determined by `(seed, players, mode, variant)`, so
/// in extensive-form terms the tree has **a single chance node at
/// the root** rather than chance distributed through it.
///
/// Three chance points exist and all three read that seed:
/// the setup deck shuffle, the setup Lead draw, and the mid-game
/// reshuffle permutation (`seed ^ round`). The **Problems deal is
/// not chance at all** — `edition::deal` is a pure function of the
/// vendored CSV.
#[test]
fn a_game_is_determined_by_its_seed() {
for players in [2u8, 3, 6] {
// **The dealt configuration, NOT the state hash.**
// `GroundState` carries `seed` as a field, so hashing the
// state makes "different seeds differ" true by
// construction — a wrong-subject error, caught by
// mutating the shuffle away and watching this stay green.
let at = |seed: u64| {
let s = setup(players, Variant::Baseline, seed);
let hands: Vec<Vec<Suit>> = s
.players
.values()
.map(|p| p.hand.iter().map(|c| c.suit).collect())
.collect();
format!("{:?}|{:?}|{:?}", hands, s.lead, s.solution_deck)
};
// Same seed, same game — twice, because "deterministic"
// that only holds once is not determinism.
assert_eq!(at(11), at(11), "{players}p: setup is not reproducible");
// And the full state still round-trips, which is the
// replay property this rests on.
assert_eq!(
cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)),
cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)),
);
// Different seeds must actually differ, or the seed is
// not the chance node and this claim is empty.
let distinct: std::collections::BTreeSet<String> = (0..12u64).map(at).collect();
assert!(
distinct.len() > 1,
"{players}p: every seed produced the same game, so the root \
chance node carries no information"
);
}
}
/// **The reshuffle is correlated with the root seed, and a real
/// table's is not** (CB-WP-0041 T02).
///
/// `draw_solution` reshuffles the discard with
/// `ChaChaRng::from_seed(seed ^ round)` — deliberate, so replay
/// never re-derives it (GameKernel K5), and the comment says so.
///
/// **The consequence is a modelling one, not a defect.** At a
/// table the reshuffle is an independent random event; here it is
/// determined by the initial shuffle and the round number. An
/// extensive-form game built from this engine inherits that
/// correlation, and would be modelling a *restriction* of the
/// game as played.
#[test]
fn the_reshuffle_permutation_is_a_function_of_seed_and_round() {
let deck: Vec<SolutionCard> = [Suit::Clarify, Suit::Repair, Suit::Boundary]
.into_iter()
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 3))
.collect();
let shuffled = |seed: u64, round: u8| {
let mut order = deck.clone();
let mut rng = ChaChaRng::from_seed(Seed(seed ^ u64::from(round)));
rng.shuffle(&mut order);
order
};
// Same seed and round: the same permutation, always.
assert_eq!(shuffled(7, 2), shuffled(7, 2));
// And it moves with BOTH inputs, or the correlation claim is
// about something that does not vary.
assert_ne!(
shuffled(7, 2),
shuffled(8, 2),
"the reshuffle does not depend on the seed"
);
assert_ne!(
shuffled(7, 2),
shuffled(7, 3),
"the reshuffle does not depend on the round"
);
}
/// **H1-A lands before the DARVO arm check** (review M5).
///
/// The delta orders it "+1 Stress, then clamp, then DARVO arm
/// check as today". CB-WP-0038 claimed this was got right and
/// nothing tested it: moving the pressure after the arm check
/// left every test green.
///
/// A seat at 4 with a Problem unclaimed must arm **in the same
/// round end** — pressure takes it to 5, and the check sees it.
#[test]
fn h1a_pressure_arms_darvo_in_the_same_round_end() {
let mut s = setup(3, Variant::H1ProblemStress, 7);
for p in s.players.values_mut() {
p.stress = 4;
p.darvo = DarvoStage::Off;
}
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
let events = s.end_round_events();
assert!(
events
.iter()
.any(|e| matches!(e, GroundEvent::DarvoTriggered { .. })),
"pressure took the seat to 5 but the arm check did not see it, \
so H1-A is ordered after it"
);
// And the order is visible in the event stream: the Stress
// must be set before the trigger, not after.
let first_stress = events
.iter()
.position(|e| matches!(e, GroundEvent::StressSet { .. }));
let first_trigger = events
.iter()
.position(|e| matches!(e, GroundEvent::DarvoTriggered { .. }));
assert!(
first_stress < first_trigger,
"the arm check precedes the pressure in the event stream"
);
}
/// **H1-B must not fire on a GROUND—OU cancellation** (review M11).
///
/// `resolve_attack` has two cancel paths and the existing test
/// exercised only Protection — it passed `&Default::default()`
/// for `ou_cancels`, so the other path was never reached.
#[test]
fn h1b_does_not_soothe_an_ou_cancelled_attack() {
let mut s = setup(3, Variant::H1ProblemStress, 3);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let (a, t) = (seats[0], seats[1]);
s.players.get_mut(&a).expect("a").stress = 4;
let mut ou = std::collections::BTreeSet::new();
ou.insert((a, t));
let mut events = Vec::new();
s.resolve_attack(a, t, &ou, &mut events);
assert!(
!events
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a)),
"an Attack cancelled by GROUND—OU still soothed the attacker"
);
}
/// **H1-B applies to the DARVO extra Attack** (review M12).
///
/// The delta says so outright — *"DARVO-stage extra Attack uses
/// the same Attack resolution (so it can self-soothe too if
/// Stress >= 4)"* — and CB-WP-0038 asserted it on the strength of
/// the code sharing `resolve_attack`. **Nothing tested it**, and
/// CB-EV-0031's withdrawn mechanism story ran through this path.
#[test]
fn h1b_soothes_the_darvo_stage_attack_too() {
let soothe_count = |variant: Variant| {
let mut s = setup(3, variant, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let (owner, target) = (seats[0], seats[1]);
s.step = RoundStep::Resolve;
s.players.get_mut(&owner).expect("o").stress = 4;
s.players.get_mut(&owner).expect("o").darvo = DarvoStage::Attack;
s.darvo_targets.insert(
owner,
DarvoTarget {
problem: None,
player: Some(target),
},
);
s.resolution_events()
.iter()
.filter(|e| {
matches!(e, GroundEvent::StressSet { player, stress }
if *player == owner && *stress == 3)
})
.count()
};
assert_eq!(
soothe_count(Variant::H1ProblemStress),
1,
"the DARVO extra Attack did not self-soothe, though the delta says it \
shares the same Attack resolution"
);
assert_eq!(
soothe_count(Variant::Baseline),
0,
"the baseline soothed on a DARVO stage Attack"
);
}
/// **Round-5 pressure must reach the score** (review #13).
///
/// `end_round_events` scored from `self` while H1-A's pressure
/// went into `work`, so the last round's Stress was invisible to
/// the GR-E03 and GR-E04 tiebreaks — the two modes CB-EV-0030
/// reports on.
///
/// Uniform pressure preserves an ordering, so the case that bites
/// is the **clamp**: two seats with equal claims, one at 5 and one
/// at 4, become equal once both are pushed to 5.
#[test]
fn the_last_rounds_pressure_reaches_the_tiebreak() {
let mut s = setup(3, Variant::H1ProblemStress, 5);
s.mode = ScoringMode::CommonProblem;
s.round = 5;
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// A CONSTRUCTED board, not the deal: the tiebreak only
// decides among seats tied at the top, and no natural 3p deal
// splits evenly while leaving a Problem unclaimed.
//
// Two Problems worth 4 (one each to the two seats, so they
// tie on personal) and one worth 1 left unclaimed, so H1-A
// fires. Claimed = 8 against a threshold of 7.
let ids: Vec<u32> = s.problems.keys().copied().collect();
for id in &ids {
s.problems.remove(id);
}
let mk = |value: u8, claimed_by: Option<PlayerId>| ProblemState {
suit: Suit::Repair,
value,
face_up: true,
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
s.problems.insert(1, mk(4, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[1])));
s.problems.insert(3, mk(1, None));
s.players.get_mut(&seats[0]).expect("a").stress = 5;
s.players.get_mut(&seats[1]).expect("b").stress = 4;
let events = s.end_round_events();
let Some(GroundEvent::GameEnded { outcome }) = events.last() else {
panic!("round 5 did not end the game");
};
assert!(outcome.group_success, "the fixture must qualify");
// **The oracle names the expected winners** (CB-REV-0002 #4).
// The first version asserted only that the set CHANGED, so
// reversing GR-E03's tiebreak — making HIGHER Stress win —
// left it green: the set still changed, just wrongly.
let pre = s.score();
assert_eq!(
pre.winners,
vec![seats[1]],
"before the pressure, the seat at Stress 4 wins on the tiebreak"
);
assert_eq!(
outcome.winners,
vec![seats[0], seats[1]],
"after it both are clamped to 5, so they share"
);
}
/// **Every tiebreak comparator, both modes** (CB-REV-0003 #4).
///
/// The strengthened oracle covered **1 of 6**: GR-E03's Stress
/// key. Reversing GR-E03's *Bond* key, or **either** of GR-E04's
/// two keys, left all 179 tests and all 26 scenarios green —
/// GR-E04's tiebreak had no coverage at all, because the only
/// GR-E04 scenario sets `group_success: false`, so the winners
/// branch returns empty and the comparator never runs.
///
/// Each case is built so exactly one key decides, and the
/// expected winner is named — asserting "the set changed" is what
/// let a reversed comparator pass in the first place.
#[test]
fn every_tiebreak_key_decides_the_way_the_rules_say() {
let board = |mode: ScoringMode| {
let mut s = setup(4, Variant::Baseline, 5);
s.mode = mode;
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let ids: Vec<u32> = s.problems.keys().copied().collect();
for id in &ids {
s.problems.remove(id);
}
(s, seats)
};
let mk = |value: u8, claimed_by: Option<PlayerId>| ProblemState {
suit: Suit::Repair,
value,
face_up: true,
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
// GR-E03 key 2, Stress: equal claims, lower Stress wins.
let (mut s, seats) = board(ScoringMode::CommonProblem);
s.problems.insert(1, mk(4, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[1])));
s.players.get_mut(&seats[0]).expect("a").stress = 5;
s.players.get_mut(&seats[1]).expect("b").stress = 1;
assert_eq!(
s.score().winners,
vec![seats[1]],
"GR-E03: lower Stress must win the tiebreak"
);
// GR-E03 key 3, Bonds: equal claims AND equal Stress, more
// Bonds wins.
s.players.get_mut(&seats[0]).expect("a").stress = 1;
s.relations
.insert(Pair::new(seats[0], seats[2]), Relation::Bond);
assert_eq!(
s.score().winners,
vec![seats[0]],
"GR-E03: with claims and Stress level, more Bonds must win"
);
// GR-E04 key 2, combined Stress: two coalitions of equal
// score, the calmer one wins.
let (mut s, seats) = board(ScoringMode::BondedCoalitions);
s.problems.insert(1, mk(4, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[2])));
s.relations
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
s.relations
.insert(Pair::new(seats[2], seats[3]), Relation::Bond);
for seat in &seats {
s.players.get_mut(seat).expect("p").stress = 1;
}
s.players.get_mut(&seats[0]).expect("p").stress = 5;
let won = s.score().winners;
assert!(
won.contains(&seats[2]) && !won.contains(&seats[0]),
"GR-E04: the coalition with lower combined Stress must win, got {won:?}"
);
// GR-E04 key 3, Blame — and reaching it takes care, which is
// the point. A coalition's score is `sum(claimed - blame)`, so
// a Blame token lowers the score too and key 1 decides first.
// The key is only reachable when the claims COMPENSATE: 5
// claimed with one Blame ties 4 claimed with none.
s.players.get_mut(&seats[0]).expect("p").stress = 1;
s.problems.insert(1, mk(5, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[2])));
s.players.get_mut(&seats[0]).expect("p").blame_from = vec![seats[3]];
let scored = s.score();
assert_eq!(
scored.coalitions.len(),
2,
"the fixture needs two coalitions to compare"
);
assert_eq!(
scored.coalitions[0].score, scored.coalitions[1].score,
"the Blame key is unreachable unless the scores tie: {:?}",
scored.coalitions
);
let won = scored.winners;
assert!(
won.contains(&seats[2]) && !won.contains(&seats[0]),
"GR-E04: with score and Stress level, fewer Blame must win, got {won:?}"
);
}
/// **`rules_delta.yaml`'s `unchanged:` list is ground-game's claim
/// about their own experiment, and it is checkable.**
///
/// Trusting it would be taking a rules statement on faith, which
/// is the habit CB-WP-0037 was written to end.
#[test]
fn h1_changes_nothing_it_said_it_would_not() {
for players in [2u8, 3, 4, 5, 6] {
let base = setup(players, Variant::Baseline, 11);
let h1 = setup(players, Variant::H1ProblemStress, 11);
// deal_and_thresholds
assert_eq!(base.problems, h1.problems, "{players}p: the deal moved");
assert_eq!(
base.threshold(),
h1.threshold(),
"{players}p: the threshold moved"
);
// start_stress: 2
for (seat, p) in &h1.players {
assert_eq!(p.stress, 2, "{players}p {seat}: starting Stress moved");
}
// relation_slots: 2 — asserted through the engine's own
// capacity check rather than a constant beside it.
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
assert!(h1.has_free_slot(seats[0]), "a fresh seat has slots");
// ground_modes / darvo_stage_table / support: the tables
// are shared code, so equality of the starting state plus
// the deltas' scope is what carries them.
assert_eq!(base.mode, h1.mode, "{players}p: scoring mode moved");
assert_eq!(
base.solution_deck, h1.solution_deck,
"{players}p: deck moved"
);
// solve_legality / support / ground_modes: the SAME menu
// in the SAME position. Comparing setup fields could not
// see a delta that forbids an action -- the review made
// SOLVE illegal under H1 and this test stayed green.
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
for seat in &seats {
let mut lb: Vec<String> = crate::bot::legal_commands(&base, *seat)
.iter()
.map(|c| format!("{c:?}"))
.collect();
let mut lh: Vec<String> = crate::bot::legal_commands(&h1, *seat)
.iter()
.map(|c| format!("{c:?}"))
.collect();
lb.sort();
lh.sort();
assert_eq!(
lb, lh,
"{players}p {seat}: H1 changed which commands are legal"
);
}
// relation_slots: 2, asserted at the BOUNDARY. The old
// check asked `has_free_slot` of a seat with no relations,
// which holds for any capacity >= 1.
if seats.len() >= 3 {
let mut s = h1.clone();
s.relations
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
assert!(s.has_free_slot(seats[0]), "one relation leaves a slot");
s.relations
.insert(Pair::new(seats[0], seats[2]), Relation::Bond);
assert!(
!s.has_free_slot(seats[0]),
"{players}p: two relations must fill both slots -- capacity moved"
);
}
// darvo_stage_table: the arm sits at Stress 5. Every
// Problem claimed, so H1-A cannot add pressure and the
// only question is where the threshold is.
let mut s = h1.clone();
for p in s.players.values_mut() {
p.stress = 4;
}
for p in s.problems.values_mut() {
p.claimed_by = Some(seats[0]);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::DarvoTriggered { .. })),
"{players}p: DARVO armed below Stress 5 -- the stage table moved"
);
}
}
}
use super::*;
use cb_events::state_hash_hex;
fn tiny_state() -> GroundState {
GroundState {
round: 1,
lead: PlayerId(0),
players: BTreeMap::from([(
PlayerId(0),
PlayerState {
stress: 2,
freedom_ready: true,
freedom_gate_lifted: false,
darvo: DarvoStage::Off,
hand: vec![SolutionCard { suit: Suit::Repair }],
protection: 0,
blame_from: vec![],
},
)]),
relations: BTreeMap::new(),
problems: BTreeMap::new(),
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,
config: crate::config::Configuration::default(),
scenario: default_scenario(),
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()
}
/// **The mastery rating counts cards; the Mode card counts points**
/// (F28, CB-WP-0047).
///
/// MODE_COOP reads: *"All claimed Problem cards form one shared
/// score. … For a mastery rating, subtract 1 for each Blame token
/// still in play and 1 for each Denied Problem."*
///
/// The shared score is the claimed **value** — `total`, which is what
/// the threshold is compared against two lines earlier. `mastery`
/// subtracts the same penalties from the claimed **count**. Both
/// readings fit the sentence; they disagree on every game where a
/// 3-point Problem is claimed, which is most of them.
///
/// **A characterisation test, not a correction.** Scoring is
/// `ground-game`'s to rule on (ADR-0015). This pins what the engine
/// does today and shows the size of the disagreement, so the ruling
/// has a number in front of it.
#[test]
fn the_mastery_rating_is_in_points_as_the_mode_card_now_says() {
let mut s = setup_3p(11);
s.mode = ScoringMode::SharedGround;
// Two claimed Problems worth 2 and 3 — five points, two cards.
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
s.problems.insert(
1,
ProblemState {
suit: Suit::Repair,
value: 2,
face_up: true,
denied: false,
claimed_by: Some(seats[0]),
protected_this_round: false,
owner: None,
scope: None,
},
);
s.problems.insert(
2,
ProblemState {
suit: Suit::Change,
value: 3,
face_up: true,
denied: false,
claimed_by: Some(seats[1]),
protected_this_round: false,
owner: None,
scope: None,
},
);
let o = s.score();
assert_eq!(o.total, 5, "the shared score is claimed VALUE");
assert_eq!(
o.mastery,
Some(5),
"mastery starts from the shared POINT total, not the card count"
);
// The board is chosen so the two readings disagree — two cards
// worth five points. Without that the test would pass under the
// reading it exists to exclude.
assert_eq!(
s.problems
.values()
.filter(|p| p.claimed_by.is_some())
.count(),
2,
"the fixture must claim a number of CARDS different from the \
POINT total, or it cannot tell the two readings apart"
);
// **The edition's own sentence, not our memory of the ruling.**
// `Modes.csv` was clarified upstream; if that clarification is
// ever reverted this goes red rather than quietly diverging.
let (card, _) = crate::edition::modes()
.expect("Modes.csv")
.into_iter()
.find(|(c, _)| c.id == "MODE_COOP")
.expect("MODE_COOP");
assert!(
card.rules_text.contains("not card count"),
"the Mode card no longer rules out the card-count reading: {:?}",
card.rules_text
);
// And the penalties still bite, in points.
let seat = seats[0];
s.players.get_mut(&seat).unwrap().blame_from = vec![seats[1]];
assert_eq!(s.score().mastery, Some(4), "a Blame token costs 1 POINT");
}
/// **Every scenario is reachable through `setup`** (CB-WP-0047).
///
/// `setup` passed the literal `"SCN_01"`. `deal` had taken a
/// scenario id since the day it was written and no caller ever
/// passed a different one — the parameter was the whole seam and it
/// sat unused, which is why three decks went unplayed without any
/// gate noticing.
#[test]
fn every_scenario_can_be_set_up_and_carries_its_own_board() {
use cb_game_runtime::{ScenarioGame, Setup};
let mut boards = std::collections::BTreeSet::new();
for s in crate::edition::scenarios().expect("Scenarios.csv") {
let preset = if s.id == "SCN_01" {
"standard-4p".to_string()
} else {
format!("scn-{}-4p", s.id.rsplit('_').next().unwrap())
};
let state = GroundState::setup(
&Setup {
players: 4,
preset: preset.clone(),
patch: Default::default(),
},
7,
)
.unwrap_or_else(|e| panic!("{preset}: {e}"));
assert_eq!(state.scenario, s.id, "{preset} dealt the wrong scenario");
boards.insert(
state
.problems
.values()
.map(|p| (format!("{:?}", p.suit), p.value))
.collect::<Vec<_>>(),
);
}
// Four scenarios, four boards (F29). This asserted THREE until
// 2026-08-08, because SCN_01 and SCN_02 were identical decks;
// ground-game ruled that unintended and re-tuned SCN_02.
assert_eq!(
boards.len(),
4,
"the four scenarios yield {} distinct boards at 4p",
boards.len()
);
}
/// The preset grammar, including what it must keep meaning.
#[test]
fn the_preset_names_a_scenario_and_a_seat_count() {
assert_eq!(parse_preset("standard-4p", 4).unwrap(), "SCN_01");
assert_eq!(parse_preset("scn-03-6p", 6).unwrap(), "SCN_03");
// Twenty-six recordings name `standard-Np`; if this ever stopped
// meaning SCN_01 every one of them would replay a different board
// while its hash still claimed to pin the old one.
assert_eq!(parse_preset("standard-2p", 2).unwrap(), "SCN_01");
// A preset that looks like an id but names no card is refused
// HERE, so the error is about the preset and not about Problems.
assert!(parse_preset("scn-09-4p", 4).is_err());
assert!(parse_preset("standard-4p", 3).is_err());
assert!(parse_preset("nonsense-4p", 4).is_err());
}
/// **The threshold comes off the Scenario card** (CB-WP-0047, F25).
#[test]
fn the_threshold_is_the_editions_and_the_fallback_is_unreachable() {
use cb_game_runtime::{ScenarioGame, Setup};
for s in crate::edition::scenarios().expect("Scenarios.csv") {
for (players, want) in [
(2u8, s.thresholds.0),
(4, s.thresholds.1),
(6, s.thresholds.2),
] {
let preset = if s.id == "SCN_01" {
format!("standard-{players}p")
} else {
format!("scn-{}-{players}p", s.id.rsplit('_').next().unwrap())
};
let state = GroundState::setup(
&Setup {
players,
preset,
patch: Default::default(),
},
3,
)
.unwrap();
assert_eq!(
state.threshold(),
want,
"{} at {players}p: the engine scored against its own number",
s.id
);
}
}
// **The real control.** Every shipped scenario prints 5/7/9, so
// the loop above passes whether the number came off the card or
// off the old hardcoded bands — verified by mutation: reverting
// to the bands left it green. This asks a card that DISAGREES.
let odd = crate::edition::ScenarioText {
id: "SCN_XX".into(),
title: "a card that disagrees".into(),
premise: String::new(),
surface_problem_id: String::new(),
hidden_problem_ids: vec![],
thresholds: (4, 6, 8),
starting_stress: String::new(),
round_track: String::new(),
};
for (seats, want) in [(2usize, 4), (4, 6), (6, 8)] {
assert_eq!(
threshold_from(std::slice::from_ref(&odd), "SCN_XX", seats),
want,
"the threshold did not come off the Scenario card"
);
}
// And the fallback is what answers for a card the edition lacks.
assert_eq!(threshold_from(&[], "SCN_XX", 4), 7);
// That fallback is unreachable through `setup`, which refuses an
// unknown scenario before dealing. A fallback nothing can reach
// is the only kind that cannot silently answer for the real one.
assert!(GroundState::setup(
&Setup {
players: 4,
preset: "scn-09-4p".into(),
patch: Default::default(),
},
3
)
.is_err());
}
/// 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 as ruled 2026-08-04: Surface always, plus hidden
// priorities 1..=k. At 3 players k = 3, so FOUR Problems are
// dealt — Surface face up, the three hidden ones face down. The
// engine used to deal Surface + (k-1), which is what made the
// group unable to reach GR-E01's threshold.
assert_eq!(
state.problems.len(),
4,
"GR-S01 deals Surface + hidden 1..=3"
);
assert!(state.problems[&1].face_up, "the Surface Problem is face up");
for slot in 2..=4 {
assert!(
!state.problems[&slot].face_up,
"hidden Problem {slot} is face up"
);
}
// The edition's values, not the stand-in's `value = priority`.
let dealt: Vec<u8> = (1..=4).map(|n| state.problems[&n].value).collect();
assert_eq!(dealt, vec![2, 2, 2, 3], "edition point_value not loaded");
}
/// 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]
fn ground_state_hashes_canonically() {
let a = tiny_state();
let b = a.clone();
assert_eq!(state_hash_hex(&a), state_hash_hex(&b));
let mut c = a.clone();
c.players.get_mut(&PlayerId(0)).unwrap().stress = 5;
assert_ne!(state_hash_hex(&a), state_hash_hex(&c));
}
}
#[cfg(test)]
mod bench_shape {
use super::*;
use cb_game_runtime::{ScenarioFile, ScenarioGame};
/// 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() {
// K18 / single source of fact (InnerLoop v1.3): the workload is
// read from the SAME file the Criterion bench replays. It used to
// be hardcoded here as well, so the round existed twice and the
// two copies could drift — editing the YAML broke `bench-test`
// while this test kept passing.
let yaml = include_str!("../../../benchmarks/synthetic-3p.yaml");
let sc = ScenarioFile::from_yaml(yaml).expect("bench workload parses");
let mut state = GroundState::setup(&sc.setup, sc.seed).unwrap();
let mut events = 0;
let mut commands = 0;
for step in &sc.commands {
let (actor, cmd) = GroundState::parse_command(step).expect("workload command");
commands += 1;
if let Ok(produced) = state.validate(actor, &cmd) {
for e in &produced {
state.fold(e);
}
events += produced.len();
}
}
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, Snapshot};
use cb_game_runtime::{ScenarioGame, Setup};
use cb_kernel::EventSeq;
use std::time::Instant;
/// A fresh game at `seats` players, for the seat-count sweep.
fn fresh_n(seats: u8) -> GroundState {
GroundState::setup(
&Setup {
players: seats,
preset: format!("standard-{seats}p"),
patch: BTreeMap::new(),
},
42,
)
.expect("a standard deal")
}
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<GroundEvent>) -> 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
}
/// K9: `snapshot + remaining events -> state` must be hash-identical to
/// a from-genesis fold.
///
/// **This is the assertion K9 did not have.** Its entire evidence was
/// one test round-tripping a `BTreeMap<String, u8>` with `EventSeq(17)`
/// as a literal — no game aggregate, no events applied, no
/// from-genesis comparison. CB-WP-0005 proved it inert by mutation:
/// making `Snapshot::take` discard its `EventSeq` and store 0 left the
/// test green, so the half of K9 that says "**+ the EventId it
/// includes**" was unverified.
///
/// Single-seed on purpose. AM-7's probe folds a log built across games
/// seeded 42, 43, 44... into a state from `fresh(42)`, which is not a
/// replay of anything; that defect is not repeated here.
#[test]
fn k9_snapshot_plus_remaining_events_equals_genesis_fold() {
let mut source = fresh(42);
let mut log = Vec::new();
while log.len() < 400 && source.outcome.is_none() {
if record_round(&mut source, &mut log) == 0 {
break;
}
}
// Positive control: a trivial log would make the comparison pass
// for the wrong reason.
assert!(
log.len() >= 50,
"K9 needs a non-trivial single-game log, got {} events",
log.len()
);
let mut genesis = fresh(42);
for e in &log {
genesis.fold(e);
}
let genesis_hash = state_hash_hex(&genesis);
let n = log.len() / 2;
let mut mid = fresh(42);
for e in &log[..n] {
mid.fold(e);
}
let snap = Snapshot::take(&mid, EventSeq(n as u64));
// The clause the mutation exposed: a snapshot is the aggregate
// **plus the EventId it includes**. Without this, `take` could
// discard `through` entirely and nothing would notice.
assert_eq!(
snap.through,
EventSeq(n as u64),
"K9: the snapshot must carry the EventSeq it includes"
);
let mut restored: GroundState = snap.restore().unwrap();
// And the snapshot must not already equal the end state, or
// "apply the remainder" would be vacuous.
assert_ne!(
state_hash_hex(&restored),
genesis_hash,
"K9: the mid-log snapshot must differ from the end state"
);
for e in &log[n..] {
restored.fold(e);
}
assert_eq!(
state_hash_hex(&restored),
genesis_hash,
"K9 UNMET: snapshot at seq {n} + {} remaining events did not \
reproduce the from-genesis fold over {} events",
log.len() - n,
log.len()
);
}
/// AM-6 target from GameKernel §5, in applied events per second.
///
/// **Pinned, not tuned.** CB-WP-0006 T01 named the trap up front: a
/// timing assertion is flaky by nature and the reflex is to loosen it
/// until it never fires, which reproduces the defect being fixed —
/// this row was `unmutatable` because *nothing in the workspace
/// compared any number to 100,000*, while the evidence file reported
/// `AM-6 | met, 16.5×`.
///
/// Measured on bnt-lap001 2026-07-31: **~182k212k ev/s in debug**,
/// **~2.4M3.1M ev/s in release**. So the spec target holds even in an
/// unoptimized build, with ~1.8× headroom there and ~24× in release.
/// **Lowering this constant requires an ADR.**
const AM6_EVENTS_PER_SEC: f64 = 100_000.0;
/// Best of N samples. A throughput *floor* asks "is this machine
/// capable", so transient load should not fail the build; taking the
/// max makes the gate robust without loosening the threshold, which is
/// the trade this task was told to avoid making on the threshold.
const AM6_SAMPLES: usize = 3;
/// AM-6: applied events/s on the synthetic workload must clear the
/// spec target. A test, not a bench — Criterion reports throughput and
/// asserts nothing, which is why this row measured nothing for six
/// passes.
///
/// **`#[ignore]` on purpose, and this is the T04 correction.** The
/// assertion first ran inside `make all` and failed at 38,753 ev/s
/// against 341,280 measured in isolation — a 9x drop, because
/// `cargo test` runs test binaries and threads **concurrently**. A
/// throughput assertion inside a parallel harness measures contention,
/// not throughput. The fix is not a lower target (T01 forbade that,
/// and it would reproduce the defect being fixed) but a measurement
/// that only runs where it is valid: `make am6`, release,
/// `--test-threads=1`.
#[test]
#[ignore = "throughput measurement — invalid under a parallel harness; run `make am6`"]
fn am6_throughput_clears_the_spec_target() {
let mut best = 0.0f64;
let mut sampled = 0usize;
for s in 0..AM6_SAMPLES {
let mut state = fresh(7 + s as u64);
let mut log = Vec::new();
let mut n = 0usize;
let t = Instant::now();
while n < 50_000 {
if state.outcome.is_some() {
state = fresh(7 + (s * 1_000_000 + n) as u64);
}
n += record_round(&mut state, &mut log);
log.clear();
}
let secs = t.elapsed().as_secs_f64();
// Positive control: a run that applied no events, or took no
// measurable time, must not be scored as infinite throughput.
assert!(
n >= 50_000,
"AM-6 harness applied {n} events, expected >= 50000"
);
assert!(secs > 0.0, "AM-6 harness measured zero elapsed time");
best = best.max(n as f64 / secs);
sampled += n;
}
let headroom = best / AM6_EVENTS_PER_SEC;
println!(
"AM-6: {best:.0} events/s (best of {AM6_SAMPLES}, {sampled} events, \
debug_assertions={}) — {headroom:.1}x the {AM6_EVENTS_PER_SEC:.0} target",
cfg!(debug_assertions)
);
assert!(
best >= AM6_EVENTS_PER_SEC,
"AM-6 UNMET: {best:.0} events/s < {AM6_EVENTS_PER_SEC:.0} \
({headroom:.2}x). Reference: ~1.7M via `make am6` on \
bnt-lap001. Do NOT lower the target to pass — GameKernel §5 \
AM-6 is a spec value and lowering it needs an ADR. If this \
fired under a parallel harness, the measurement is invalid \
rather than the target: run `make am6`.",
);
}
/// 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);
// Per-game segments with the hash of the state that produced
// them. AM-7's `hash-identical` clause was withdrawn because
// the fold ran a multi-seed log into a single genesis state
// and then never compared the hash to anything. Segments make
// the comparison meaningful: each is a real replay.
let mut segments: Vec<(u64, usize, String)> = Vec::new();
let mut seed = 42u64;
let mut source = fresh(seed);
let mut seg_start = 0usize;
let mut stalls = 0;
while log.len() < target {
if source.outcome.is_some() {
segments.push((seed, seg_start, state_hash_hex(&source)));
seg_start = log.len();
seed += 1;
source = fresh(seed);
}
if record_round(&mut source, &mut log) == 0 {
stalls += 1;
assert!(stalls < 10, "round produced no events; builder stalled");
}
}
segments.push((seed, seg_start, state_hash_hex(&source)));
// AM-7 hash-identical, re-earned (CB-WP-0006 T06). Replay each
// segment from its own genesis and require the recorded hash.
let mut ends: Vec<usize> = segments.iter().skip(1).map(|s| s.1).collect();
ends.push(log.len());
let mut verified = 0usize;
for ((seg_seed, start, want), end) in segments.iter().zip(ends) {
let mut st = fresh(*seg_seed);
for e in &log[*start..end] {
st.fold(e);
}
assert_eq!(
&state_hash_hex(&st),
want,
"AM-7 hash-identical UNMET: replaying segment seeded \
{seg_seed} ({start}..{end}) did not reproduce its \
recorded state hash"
);
verified += 1;
}
// Positive control: verifying zero segments would pass vacuously.
assert!(
verified >= 2,
"AM-7 needs several segments to verify, got {verified}"
);
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");
}
}
/// **GD-0001, INVERTED 2026-08-04.** Group success is reachable at
/// every seat count.
///
/// This test used to assert the opposite, and it was right to: the
/// maintainer played several 3-player games on 2026-08-03 and could
/// not win any of them, because GR-S01 dealt 2/3/4 Problems worth
/// 3/6/10 against thresholds of 5/7/9.
///
/// ground-game ruled the deal on 2026-08-04 — **Surface always, plus
/// hidden priorities 1..=k** — which with this edition's values gives
/// **6 / 9 / 12**. The ruling said to invert this test rather than
/// retire it, and that is why it is still here: a reader learns the
/// game *became* winnable, not that a test quietly vanished.
///
/// It reads both numbers out of the engine — the deal and
/// `threshold` — so it cannot drift from the rules it tests.
///
/// **The 2p case is the one to watch.** 2+2+2 against a threshold of
/// 5 means a full clear: any two Problems sum to 4. Reachable is not
/// forgiving, and ground-game kept that deliberately.
#[test]
fn gd0001_group_success_is_reachable_at_every_seat_count() {
let mut verdicts = Vec::new();
for seats in 2..=6u8 {
let state = fresh_n(seats);
let best: u32 = state.problems.values().map(|p| u32::from(p.value)).sum();
let need = state.threshold();
verdicts.push((seats, state.problems.len(), best, need, best >= need));
println!(
" {seats}p: {} problem(s) worth {best} against a threshold of {need} \u{2014} {}",
state.problems.len(),
if best >= need {
"reachable"
} else {
"UNREACHABLE"
}
);
}
let unreachable: Vec<u8> = verdicts
.iter()
.filter(|(_, _, _, _, ok)| !ok)
.map(|(s, _, _, _, _)| *s)
.collect();
assert!(
unreachable.is_empty(),
"group success is unreachable at {unreachable:?} seats — the \
ruled deal (Surface + hidden 1..=k) is not what the engine \
deals, or the edition values changed"
);
// The ruled numbers, asserted rather than implied: 6/9/12 against
// 5/7/9. A deal that was reachable for the wrong reason — more
// Problems, or richer ones — would pass the check above.
let available: Vec<u32> = verdicts.iter().map(|(_, _, b, _, _)| *b).collect();
assert_eq!(
available,
vec![6, 9, 9, 12, 12],
"available points are not the 6/9/12 ground-game ruled against"
);
// Positive control: a harness that measured nothing would report
// an empty `unreachable` and pass.
assert_eq!(verdicts.len(), 5, "the sweep did not cover 2..=6 seats");
}
/// AM-7 scaling floor from GameKernel §5: fold throughput at 100k
/// events must be at least this fraction of throughput at 5k.
///
/// **Pinned, not tuned** — same rule as `AM6_EVENTS_PER_SEC`. The
/// baseline it was written against is boardgame.io at 0.450.66×,
/// degrading to DNF at 100k. Lowering it requires an ADR.
const AM7_SCALING_FLOOR: f64 = 0.9;
/// The window that is timed, and how deep the late one sits.
/// **Both timed windows are the same size** — that is the correction
/// (CB-WP-0021 T06); see `paired_ratio`.
const AM7_WINDOW: usize = 5_000;
const AM7_DEPTH: usize = 100_000;
/// Events applied **per leg** per sample.
///
/// Sized against the machine's drift, not against timer resolution.
/// At 2,000,000 a sample took ~50 ms, and this machine's throughput
/// wanders by 2.5× over a few seconds (CB-EV-0013 §1) — so a 50 ms
/// sample measures whatever the clock happened to be doing. At 10 M
/// each leg runs ~0.35 s and averages over the drift instead of
/// sampling a point on it. Measured: medians 0.987 / 0.991 / 0.989
/// across three runs, and 0.989 under 8-way CPU contention. Doubling
/// this to 20 M cost 9 s more per run and did not tighten them.
const AM7_EVENTS_PER_LEG: usize = 10_000_000;
/// Paired samples per run. Nine rather than AM-6's three because the
/// verdict is a **median**, not a best-of: a median needs enough
/// samples that one excursion cannot move it.
const AM7_SAMPLES: usize = 9;
/// Fraction of samples that must agree with the median's verdict for
/// the run to be a measurement rather than noise.
///
/// **This replaced a unanimity guard that was measurably wrong.** The
/// first version declared INDETERMINATE whenever any sample fell on
/// the other side of the floor. Under deliberate 8-way CPU contention
/// the ratio held at a median of 0.971 — the pairing works, and
/// absolute throughput had dropped 4× — but one sample read 0.899,
/// a thousandth under the floor, and the guard turned a good
/// measurement into a failed build. It also fired intermittently
/// inside `mutation-check`, where this test runs straight after a
/// 50-second rebuild.
///
/// A gate that fails when the machine is busy is a flake, and a flake
/// gets suppressed rather than fixed. Requiring a two-thirds majority
/// keeps the guard's purpose — refusing to read a coin-flip as a
/// verdict — without treating a single outlier as one.
const AM7_AGREEMENT: f64 = 2.0 / 3.0;
/// Fold `n` events from `log` starting at `from`, on a state already
/// advanced to `from`, returning only the time inside the fold loop.
/// The state after folding `log[..depth]` — the history the window
/// will be folded on top of.
///
/// Built **once per sample**, not once per repetition. The first
/// version re-walked the prefix every rep: 2,000 reps x 100,000
/// events is 200M untimed folds per sample, and under the
/// history-proportional mutation that is quadratic and never
/// finishes. A control that cannot be run is not a control.
fn state_at(log: &[GroundEvent], depth: usize) -> GroundState {
let mut state = fresh(42);
for e in &log[..depth] {
state.fold(e);
}
state
}
/// Fold `n` events from `at` onto a clone of `state`, returning only
/// the time inside the fold loop. The clone is outside the clock.
fn fold_window(
state: &GroundState,
log: &[GroundEvent],
at: usize,
n: usize,
) -> std::time::Duration {
let mut st = state.clone();
let t = Instant::now();
for e in &log[at..at + n] {
st.fold(e);
}
let dt = t.elapsed();
std::hint::black_box(&st);
dt
}
/// One paired sample: the SAME window size at two history depths.
///
/// **The corrected measurement (CB-WP-0021 T06).** The previous one
/// folded a 5,000-event log and a 100,000-event log and compared
/// their throughputs, which confounds two different things:
///
/// 1. does cost per event grow with how many events have already
/// been folded? — the property AM-7 claims; and
/// 2. does streaming a 20x longer `Vec` cost more per element? — a
/// memory-hierarchy fact true of any program.
///
/// It measured (2) and reported it as (1). Importing the edition data
/// enlarged the aggregate — four Problems instead of three, real
/// values — and the ratio fell 0.97 -> 0.845 against a 0.9 floor
/// **with the state provably bounded**: identical deck, discard,
/// Problem and hand sizes after 5k and 100k events. A row that fails
/// because the game got bigger, while the property it names is
/// untouched, is measuring the wrong thing.
///
/// So: time a 5,000-event window at depth 0, and the same-sized window
/// at depth 100,000. Equal windows mean equal streaming cost, and the
/// only difference left is history depth — which is the claim.
fn paired_ratio(log: &[GroundEvent]) -> (f64, f64, f64) {
let reps = AM7_EVENTS_PER_LEG.div_ceil(AM7_WINDOW);
let early = state_at(log, 0);
let late = state_at(log, AM7_DEPTH);
let (mut t_early, mut t_late) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
for _ in 0..reps {
// Interleaved, so this machine's 2.5x drift is common-mode
// and divides out (CB-EV-0013 section 1).
// THE SAME EVENTS on both legs. Timing log[0..W] against
// log[DEPTH..DEPTH+W] compared two different event mixes and
// read 0.573 on code whose state is provably bounded — a
// second confound, introduced while removing the first.
// Identical events mean the only difference left is how much
// history the state carries, which is the claim.
t_early += fold_window(&early, log, 0, AM7_WINDOW);
t_late += fold_window(&late, log, 0, AM7_WINDOW);
}
assert!(
t_early.as_secs_f64() > 0.0 && t_late.as_secs_f64() > 0.0,
"AM-7 measured zero elapsed time"
);
let n = (reps * AM7_WINDOW) as f64;
let tp_early = n / t_early.as_secs_f64();
let tp_late = n / t_late.as_secs_f64();
(tp_early, tp_late, tp_late / tp_early)
}
/// Build one growing log of at least `target` events, the same way
/// `replay_100k_events_is_linear_and_fast` does.
fn growing_log(target: usize) -> Vec<GroundEvent> {
let mut log = Vec::with_capacity(target);
let mut seed = 42u64;
let mut source = fresh(seed);
let mut stalls = 0;
while log.len() < target {
if source.outcome.is_some() {
seed += 1;
source = fresh(seed);
}
if record_round(&mut source, &mut log) == 0 {
stalls += 1;
assert!(stalls < 10, "round produced no events; builder stalled");
}
}
log
}
/// AM-7's `scaling >= 0.9x` clause, which was inert for nine passes.
///
/// `mutation-check.py` said it plainly every run: *"no code computes
/// the ratio of throughput @100k to @5k or compares it to 0.9;
/// Criterion reports both and nothing relates them."* The sibling test
/// above is even named `replay_100k_events_is_linear_and_fast` and
/// checks the two sizes **independently** — it computes both numbers,
/// prints both, and never divides one by the other.
///
/// **`#[ignore]` for the same reason AM-6 is** (CB-WP-0006 T04): a
/// throughput assertion inside a parallel `cargo test` harness
/// measures contention. A *ratio* of two such timings is worse, not
/// better — the noise multiplies rather than cancels. Run `make am7`.
#[test]
#[ignore = "throughput ratio — invalid under a parallel harness; run `make am7`"]
fn am7_cost_per_event_does_not_grow_with_history() {
let log = growing_log(AM7_DEPTH + AM7_WINDOW);
// Positive control on the shape of the measurement. The windows
// must be the same size — that is the correction — and the late
// one must actually sit deep in the log. A harness that measured
// depth 0 twice would report ~1.0 and look excellent.
assert!(
log.len() >= AM7_DEPTH + AM7_WINDOW,
"log is {} events, too short for a window at depth {AM7_DEPTH}",
log.len()
);
const { assert!(AM7_DEPTH >= 15 * AM7_WINDOW) };
let mut ratios = Vec::with_capacity(AM7_SAMPLES);
for _ in 0..AM7_SAMPLES {
let (tp_early, tp_late, ratio) = paired_ratio(&log);
println!(
" AM-7 sample: {tp_early:.0} ev/s at depth 0 → \
{tp_late:.0} ev/s at depth {AM7_DEPTH} = {ratio:.3}x"
);
ratios.push(ratio);
}
ratios.sort_by(|a, b| a.partial_cmp(b).expect("no NaN ratios"));
let (worst, median, best) = (
ratios[0],
ratios[ratios.len() / 2],
ratios[ratios.len() - 1],
);
println!(
"AM-7 scaling: {worst:.3}x / {median:.3}x / {best:.3}x \
(worst/median/best of {AM7_SAMPLES}, floor {AM7_SCALING_FLOOR})"
);
// The spread is reported, not hidden behind a best-of. The verdict
// is the median, and it counts as a measurement only if a
// two-thirds majority of samples agree with it — see
// AM7_AGREEMENT for the unanimity guard this replaced and why.
let agreeing = ratios
.iter()
.filter(|r| (**r >= AM7_SCALING_FLOOR) == (median >= AM7_SCALING_FLOOR))
.count();
let agreement = agreeing as f64 / ratios.len() as f64;
assert!(
agreement >= AM7_AGREEMENT,
"AM-7 INDETERMINATE: only {agreeing} of {} samples agree with \
the median ({worst:.3}x..{best:.3}x around \
{AM7_SCALING_FLOOR}). The machine is too noisy for this to be \
a measurement; do not read the best sample as a pass.",
ratios.len()
);
assert!(
median >= AM7_SCALING_FLOOR,
"AM-7 UNMET: folding a {AM7_WINDOW}-event window at history \
depth {AM7_DEPTH} runs at {median:.3}x the same window at \
depth 0 (worst {worst:.3}x, best {best:.3}x), below the \
{AM7_SCALING_FLOOR} floor. Cost per event is growing with \
history — check whether something in the aggregate grows \
without bound. Baseline: boardgame.io 0.45-0.66x, DNF at \
100k. Do NOT lower the floor to pass — GameKernel §5 AM-7 is \
a spec value and lowering it needs an ADR."
);
}
}