//! games-ground — the GROUND rules aggregate (specs/GroundRules.md, //! GameKernel K15–K16). Every rule realized here names its GR-id in a doc //! comment, giving a greppable rule→code→scenario chain. /// 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; /// 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 0–5. 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, pub protection: u8, /// Blame tokens in front of this player (GR-T02), keyed by owner. pub blame_from: Vec, } #[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, /// GR-A11: protected from Deny this round by GROUND—OU. pub protected_this_round: bool, } #[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(&self, serializer: S) -> Result { serializer.collect_str(self) } } impl<'de> Deserialize<'de> for Pair { fn deserialize>(deserializer: D) -> Result { let raw = String::deserialize(deserializer)?; let (a, b) = raw .split_once('-') .ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?; let parse = |s: &str| { s.parse::() .map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}"))) }; Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?))) } } /// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered /// collections only (GameKernel K6). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GroundState { pub round: u8, pub lead: PlayerId, pub players: BTreeMap, /// Keyed by (low, high) player pair. pub relations: BTreeMap, pub problems: BTreeMap, pub solution_deck: Vec, pub solution_discard: Vec, /// Focus placements: sequence owner → target (GR-T03). pub focus: BTreeMap, /// GR-R01: which of the four steps the round is in. pub step: RoundStep, /// GR-R02: face-down selections, hidden until Reveal. pub selections: BTreeMap, /// GR-R05: GROUND modes chosen after Reveal, before Resolve. pub ground_modes: BTreeMap, /// GR-A11/A12: the sub-choice accompanying an OU or ND mode. pub ground_choices: BTreeMap, /// GR-L02/A05: a Support target's response, keyed by target. pub support_responses: BTreeMap, /// GR-D03/D04: the mandatory target a DARVO stage needs this round. pub darvo_targets: BTreeMap, /// GR-E02..E04: which scoring mode this game uses. pub mode: ScoringMode, /// 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. #[serde(default)] pub variant: Variant, /// GR-R09: set once the game has ended and scoring has run. pub outcome: Option, /// GR-S04/U4: retained so a deck reshuffle stays a pure function of /// state, keeping `validate` deterministic without holding RNG state. pub seed: u64, } /// GR-E02..E04: the three scoring modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ScoringMode { /// GR-E02, co-op: one shared score against the threshold. SharedGround, /// GR-E03, semi-co-op: personal scores once the group qualifies. CommonProblem, /// GR-E04: Bond networks score together. BondedCoalitions, } /// 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, /// `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 { /// 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", } } } impl std::str::FromStr for Variant { type Err = String; fn from_str(s: &str) -> Result { match s { "ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline), "h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress), other => Err(format!( "unknown variant {other:?} (ground-darvo-r0, h1-problem-stress)" )), } } } /// GR-E01..E04: the final scoring result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Outcome { /// GR-E01: summed printed values of all claimed Problems. pub total: u32, pub threshold: u32, pub group_success: bool, /// GR-E03: claimed value −1 per Blame held. pub personal: BTreeMap, /// GR-E04: each Bond-connected group and its combined score. pub coalitions: Vec, /// GR-E02: only meaningful in SHARED GROUND. pub mastery: Option, pub winners: Vec, } /// GR-E04: one Bond-connected group. Unbonded players are solo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Coalition { pub members: Vec, pub score: i32, } /// GR-D03/D04: what a DARVO stage acts on this round. DENY names a /// Problem, ATTACK names a player; REVERSE takes its target from the /// Focus token placed by the ATTACK stage. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct DarvoTarget { pub problem: Option, pub player: Option, } /// GR-A10..A12: the three GROUND modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum GroundMode { /// Ground & Restate (GR-A10). Gr, /// Observe & Uphold (GR-A11). Ou, /// Name & Decide (GR-A12). Nd, } /// GR-A11/A12: the sub-choice a GROUND—OU or GROUND—ND player makes /// alongside the mode. GROUND—GR takes none. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "choice")] pub enum GroundChoice { /// GR-A11: restore one Denied Problem. RestoreProblem { problem: u32 }, /// GR-A11: cancel one Attack targeting this player this round. CancelAttack { attacker: PlayerId }, /// GR-A11: protect one face-up Problem from Deny this round. ProtectProblem { problem: u32 }, /// GR-A12: remove one Blame token from this player. RemoveBlame { owner: PlayerId }, /// GR-A12: break one relation involving this player. BreakRelation { with: PlayerId }, /// GR-A12: reject one Reverse targeting this player this round. RejectReverse, } impl GroundChoice { /// GR-A11/A12: each choice belongs to exactly one mode. fn mode(self) -> GroundMode { match self { GroundChoice::RestoreProblem { .. } | GroundChoice::CancelAttack { .. } | GroundChoice::ProtectProblem { .. } => GroundMode::Ou, GroundChoice::RemoveBlame { .. } | GroundChoice::BreakRelation { .. } | GroundChoice::RejectReverse => GroundMode::Nd, } } #[cfg(feature = "scenarios")] fn parse(raw: &str, arg: Option) -> Result { let need = |what: &str| { arg.ok_or_else(|| format!("GROUND choice {raw:?} needs a {what} argument")) }; match raw { "restore_problem" => Ok(GroundChoice::RestoreProblem { problem: need("problem")? as u32, }), "cancel_attack" => Ok(GroundChoice::CancelAttack { attacker: PlayerId(need("seat")? as u8), }), "protect_problem" => Ok(GroundChoice::ProtectProblem { problem: need("problem")? as u32, }), "remove_blame" => Ok(GroundChoice::RemoveBlame { owner: PlayerId(need("seat")? as u8), }), "break_relation" => Ok(GroundChoice::BreakRelation { with: PlayerId(need("seat")? as u8), }), "reject_reverse" => Ok(GroundChoice::RejectReverse), other => Err(format!("unknown GROUND choice {other:?}")), } } } /// GR-L02/A05: how a Support target responds, chosen after Reveal. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SupportResponse { /// GR-L02: accept a Bond where no relation exists. AcceptBond, /// GR-L02: decline it; the Stress effect still applies. DeclineBond, /// GR-A05: turn an existing Rivalry into a Bond. FlipToBond, /// GR-A05: break the existing Rivalry. BreakRivalry, } impl SupportResponse { #[cfg(feature = "scenarios")] fn parse(raw: &str) -> Result { match raw { "accept_bond" => Ok(SupportResponse::AcceptBond), "decline_bond" => Ok(SupportResponse::DeclineBond), "flip_to_bond" => Ok(SupportResponse::FlipToBond), "break_rivalry" => Ok(SupportResponse::BreakRivalry), other => Err(format!("unknown support response {other:?}")), } } } impl GroundMode { #[cfg(feature = "scenarios")] fn parse(raw: &str) -> Result { match raw { "GR" => Ok(GroundMode::Gr), "OU" => Ok(GroundMode::Ou), "ND" => Ok(GroundMode::Nd), other => Err(format!( "unknown GROUND mode {other:?} (expected GR, OU or ND)" )), } } } /// GR-R01: Select → Reveal → Resolve → End. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RoundStep { Select, Reveal, Resolve, End, } /// GR-R02: one player's face-down choice, with its target where the /// Action requires one (GR-A13). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Selection { pub action: Action, pub target: Option, pub problem: Option, } /// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal /// (GR-R05), not at Select, so it is not part of the selection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Action { Investigate, Solve, Support, Attack, Ground, } impl Action { #[cfg(feature = "scenarios")] fn parse(raw: &str) -> Result { match raw { "INVESTIGATE" => Ok(Action::Investigate), "SOLVE" => Ok(Action::Solve), "SUPPORT" => Ok(Action::Support), "ATTACK" => Ok(Action::Attack), "GROUND" => Ok(Action::Ground), other => Err(format!("unknown action {other:?}")), } } /// GR-R03: the stress gate admits only ATTACK and GROUND. fn allowed_under_stress_gate(self) -> bool { matches!(self, Action::Attack | Action::Ground) } /// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and /// SOLVE target a Problem; GROUND targets neither at Select. fn requires_player_target(self) -> bool { matches!(self, Action::Support | Action::Attack) } fn requires_problem_target(self) -> bool { matches!(self, Action::Investigate | Action::Solve) } } /// Commands accepted by the GROUND aggregate. #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroundCommand { /// GR-R02: choose an Action face down. SelectAction { action: Action, target: Option, problem: Option, }, /// GR-R03: spend the READY Freedom token to bypass the stress gate. SpendFreedom, /// GR-R05: a player who revealed GROUND chooses its mode after /// seeing all revealed Actions. ChooseGroundMode { mode: GroundMode, choice: Option, }, /// GR-L02/A05: respond to a Support aimed at this player. RespondToSupport { response: SupportResponse }, /// GR-D03/D04: name the mandatory target of this round's stage. ChooseDarvoTarget { target: DarvoTarget }, /// GR-R04: reveal all selections simultaneously. System-driven. Reveal, /// GR-R06/R07: resolve revealed Actions in fixed step order, Lead /// first. System-driven. Resolve, /// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the /// Round marker. System-driven. EndRound, } /// Events the aggregate emits. `fold` is total over these (K1). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum GroundEvent { ActionSelected { player: PlayerId, selection: Selection, }, FreedomSpent { player: PlayerId, }, /// GR-R04. Revealed, /// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial. StressSet { player: PlayerId, stress: u8, }, /// GR-F04. FreedomReadied { player: PlayerId, }, /// GR-L02/L03: endpoints ordered low→high (GR-O05). RelationFormed { pair: Pair, relation: Relation, }, /// GR-L04. RelationBroken { pair: Pair, }, /// GR-A09: Protection absorbed an Attack. AttackCancelled { attacker: PlayerId, target: PlayerId, }, /// GR-R05/A11/A12. GroundModeChosen { player: PlayerId, mode: GroundMode, choice: Option, }, /// GR-L02/A05. SupportAnswered { player: PlayerId, response: SupportResponse, }, /// GR-A11: a Denied Problem was restored face up. ProblemRestored { problem: u32, }, /// GR-A11: a face-up Problem is protected from Deny this round. ProblemProtected { problem: u32, }, /// GR-A12/T02: a Blame token was removed and returned to its owner. BlameRemoved { player: PlayerId, owner: PlayerId, }, /// GR-A01: a hidden Problem was turned face up. ProblemRevealed { problem: u32, }, /// GR-A01: one Solution drawn from the deck. SolutionDrawn { player: PlayerId, card: SolutionCard, }, /// GR-A02: a matching Solution was spent to claim a Problem. SolutionDiscarded { player: PlayerId, card: SolutionCard, }, /// GR-A02. ProblemClaimed { problem: u32, by: PlayerId, }, /// GR-A01 under the U4 default: the discard was reshuffled into the /// deck. The resulting order travels in the event, so `fold` stays /// deterministic without replaying the RNG. DeckReshuffled { order: Vec, }, /// GR-D01: Stress 5 at End with the marker OFF. DarvoTriggered { player: PlayerId, }, /// GR-D03/D04. DarvoTargetChosen { player: PlayerId, target: DarvoTarget, }, /// GR-D03: a Problem was turned face down and Denied. ProblemDenied { problem: u32, }, /// GR-D04/T03: the sequence owner's Focus token was placed. FocusPlaced { owner: PlayerId, target: PlayerId, }, /// GR-D05/T03: Focus flipped to Blame in front of the target. FocusFlippedToBlame { owner: PlayerId, target: PlayerId, }, /// GR-D05/T01. ProtectionGained { player: PlayerId, }, /// GR-D02: the sequence moved to its next stage. DarvoAdvanced { player: PlayerId, stage: DarvoStage, }, /// GR-D05/D06/D07: the sequence ended and the marker is OFF again. DarvoEnded { player: PlayerId, }, /// GR-R09/E01..E04: the game ended and scoring ran. GameEnded { outcome: Outcome, }, /// GR-R01: the round advanced to `step`. StepAdvanced { step: RoundStep, }, /// GR-R08: Lead rotated and the Round marker advanced. RoundEnded { round: u8, next_lead: PlayerId, }, } impl GroundState { /// GR-R03: a player at Stress 4–5 is gated unless Freedom is spent. /// Spending flips the token to SPENT, so the gate returns next round. fn stress_gated(&self, player: &PlayerState) -> bool { let _ = self; player.stress >= 4 && !player.freedom_gate_lifted } fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option { self.relations.get(&Pair::new(a, b)).copied() } /// GR-L01: two relation slots per player. fn has_free_slot(&self, player: PlayerId) -> bool { let used = self .relations .keys() .filter(|pair| pair.contains(player)) .count(); used < 2 } /// GR-R07: resolution order starts at the Lead and continues /// clockwise (ascending seat, wrapping). fn seat_order(&self) -> Vec { let seats: Vec = self.players.keys().copied().collect(); let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0); seats[start..] .iter() .chain(&seats[..start]) .copied() .collect() } /// GR-F01 with the U2 default: clamp on every application, so no /// intermediate value escapes 0–5. fn stress_after(&self, player: PlayerId, delta: i16) -> u8 { let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress)); current.saturating_add(delta).clamp(0, 5) as u8 } fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> { self.players.get(&id).ok_or(Rejection::Game { code: "no-such-seat".into(), detail: format!("player {id} is not in this game"), }) } } impl Aggregate for GroundState { type Command = GroundCommand; type Event = GroundEvent; fn validate( &self, actor: Actor, command: &Self::Command, ) -> Result, Rejection> { // GR-R04/R06/R08 are runtime-driven, not player-issued. match command { GroundCommand::Reveal => { if self.step != RoundStep::Select || actor != Actor::System { return Err(Rejection::NotAllowedNow); } if self.selections.len() != self.players.len() { return Err(Rejection::Game { code: "select-incomplete".into(), detail: "GR-R04: every player must select before Reveal".into(), }); } return Ok(vec![ GroundEvent::Revealed, GroundEvent::StepAdvanced { step: RoundStep::Reveal, }, ]); } GroundCommand::Resolve => { if self.step != RoundStep::Reveal || actor != Actor::System { return Err(Rejection::NotAllowedNow); } // GR-R05: modes are chosen before resolution begins. let pending: Vec = self .selections .iter() .filter(|(seat, sel)| { sel.action == Action::Ground && !self.ground_modes.contains_key(seat) }) .map(|(seat, _)| *seat) .collect(); if !pending.is_empty() { return Err(Rejection::Game { code: "ground-mode-pending".into(), detail: format!("GR-R05: no GROUND mode chosen for {pending:?}"), }); } return Ok(self.resolution_events()); } GroundCommand::EndRound => { if self.step != RoundStep::Resolve || actor != Actor::System { return Err(Rejection::NotAllowedNow); } return Ok(self.end_round_events()); } _ => {} } let Actor::Player(id) = actor else { return Err(Rejection::NotAllowedNow); }; let player = self.player(id)?; match command { // GR-R02: one face-down choice per player, during Select only. GroundCommand::SelectAction { action, target, problem, } => { if self.step != RoundStep::Select { return Err(Rejection::NotAllowedNow); } if self.selections.contains_key(&id) { return Err(Rejection::DuplicateCommand); } if self.stress_gated(player) && !action.allowed_under_stress_gate() { return Err(Rejection::Game { code: "stress-gate".into(), detail: "GR-R03: at Stress 4–5 only ATTACK or GROUND may be selected" .into(), }); } self.check_targeting(id, *action, *target, *problem)?; Ok(vec![GroundEvent::ActionSelected { player: id, selection: Selection { action: *action, target: *target, problem: *problem, }, }]) } // GR-R03: spendable during Select, before Reveal, once. GroundCommand::SpendFreedom => { if self.step != RoundStep::Select { return Err(Rejection::NotAllowedNow); } if !player.freedom_ready { return Err(Rejection::Game { code: "freedom-spent".into(), detail: "GR-F03: the Freedom token is already SPENT".into(), }); } Ok(vec![GroundEvent::FreedomSpent { player: id }]) } // GR-R05: only after Reveal, only for a player who revealed // GROUND, once. GroundCommand::ChooseGroundMode { mode, choice } => { if self.step != RoundStep::Reveal { return Err(Rejection::NotAllowedNow); } let revealed_ground = self .selections .get(&id) .is_some_and(|s| s.action == Action::Ground); if !revealed_ground { return Err(Rejection::Game { code: "no-ground-revealed".into(), detail: "GR-R05: only a player who revealed GROUND chooses a mode".into(), }); } if self.ground_modes.contains_key(&id) { return Err(Rejection::DuplicateCommand); } let _ = player; // GR-A10..A12: GR takes no sub-choice; OU and ND each // require one drawn from their own list. match (mode, choice) { (GroundMode::Gr, None) => {} (GroundMode::Gr, Some(_)) => { return Err(Rejection::Game { code: "unexpected-choice".into(), detail: "GR-A10: GROUND—GR takes no sub-choice".into(), }) } (wanted, Some(choice)) if choice.mode() == *wanted => {} (wanted, _) => { return Err(Rejection::Game { code: "bad-choice".into(), detail: format!("GR-A11/A12: {wanted:?} needs one of its own choices"), }) } } self.check_ground_choice(id, *choice)?; Ok(vec![GroundEvent::GroundModeChosen { player: id, mode: *mode, choice: *choice, }]) } // GR-L02/A05: only the target of a revealed Support answers, // once, and only with a response its relation admits. GroundCommand::RespondToSupport { response } => { if self.step != RoundStep::Reveal { return Err(Rejection::NotAllowedNow); } let supporter = self.selections.iter().find(|(seat, sel)| { sel.action == Action::Support && sel.target == Some(id) && **seat != id }); let Some((supporter, _)) = supporter else { return Err(Rejection::Game { code: "no-support-received".into(), detail: "GR-L02: no revealed Support targets this player".into(), }); }; if self.support_responses.contains_key(&id) { return Err(Rejection::DuplicateCommand); } let admitted = match self.relation_between(*supporter, id) { // GR-L02: no relation — the target may accept a Bond. None => matches!( response, SupportResponse::AcceptBond | SupportResponse::DeclineBond ), // GR-A05: through a Rivalry — flip it or break it. Some(Relation::Rivalry) => matches!( response, SupportResponse::FlipToBond | SupportResponse::BreakRivalry ), // GR-A04: through a Bond — nothing to answer. Some(Relation::Bond) => false, }; if !admitted { return Err(Rejection::Game { code: "bad-response".into(), detail: format!("GR-L02/A05: {response:?} is not available here"), }); } Ok(vec![GroundEvent::SupportAnswered { player: id, response: *response, }]) } // GR-D03/D04: only a player with a live sequence, after // Reveal, once per round. GroundCommand::ChooseDarvoTarget { target } => { if self.step != RoundStep::Reveal { return Err(Rejection::NotAllowedNow); } if player.darvo == DarvoStage::Off { return Err(Rejection::Game { code: "no-darvo-sequence".into(), detail: "GR-D02: this player has no live DARVO sequence".into(), }); } if self.darvo_targets.contains_key(&id) { return Err(Rejection::DuplicateCommand); } match player.darvo { // GR-D03: a face-up, unsolved, unprotected Problem. DarvoStage::Deny => { let problem = target.problem.ok_or(Rejection::Game { code: "bad-darvo-target".into(), detail: "GR-D03: DENY names a Problem".into(), })?; let eligible = self.problems.get(&problem).is_some_and(|p| { p.face_up && !p.denied && p.claimed_by.is_none() && !p.protected_this_round }); if !eligible { return Err(Rejection::Game { code: "bad-darvo-target".into(), detail: format!( "GR-D03: Problem {problem} is not face-up, unsolved and unprotected" ), }); } } // GR-D04: an extra Attack against another player. DarvoStage::Attack => { let other = target.player.ok_or(Rejection::Game { code: "bad-darvo-target".into(), detail: "GR-D04: ATTACK names a player".into(), })?; if other == id || !self.players.contains_key(&other) { return Err(Rejection::Game { code: "bad-darvo-target".into(), detail: "GR-D04: ATTACK targets another player".into(), }); } } // GR-D05: REVERSE uses the placed Focus token. DarvoStage::Reverse | DarvoStage::Off => {} } Ok(vec![GroundEvent::DarvoTargetChosen { player: id, target: *target, }]) } GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => { unreachable!("system commands are handled above") } } } fn fold(&mut self, event: &Self::Event) { match event { GroundEvent::ActionSelected { player, selection } => { self.selections.insert(*player, *selection); } GroundEvent::FreedomSpent { player } => { if let Some(state) = self.players.get_mut(player) { state.freedom_ready = false; state.freedom_gate_lifted = true; } } GroundEvent::Revealed => {} GroundEvent::StressSet { player, stress } => { if let Some(state) = self.players.get_mut(player) { state.stress = *stress; } } GroundEvent::FreedomReadied { player } => { if let Some(state) = self.players.get_mut(player) { state.freedom_ready = true; } } GroundEvent::RelationFormed { pair, relation } => { self.relations.insert(*pair, *relation); } GroundEvent::RelationBroken { pair } => { self.relations.remove(pair); } GroundEvent::AttackCancelled { target, .. } => { if let Some(state) = self.players.get_mut(target) { state.protection = state.protection.saturating_sub(1); } } GroundEvent::GroundModeChosen { player, mode, choice, } => { self.ground_modes.insert(*player, *mode); if let Some(choice) = choice { self.ground_choices.insert(*player, *choice); } } GroundEvent::SupportAnswered { player, response } => { self.support_responses.insert(*player, *response); } GroundEvent::ProblemRestored { problem } => { if let Some(state) = self.problems.get_mut(problem) { state.denied = false; state.face_up = true; } } GroundEvent::ProblemProtected { problem } => { if let Some(state) = self.problems.get_mut(problem) { state.protected_this_round = true; } } GroundEvent::BlameRemoved { player, owner } => { if let Some(state) = self.players.get_mut(player) { if let Some(pos) = state.blame_from.iter().position(|o| o == owner) { state.blame_from.remove(pos); } } } GroundEvent::ProblemRevealed { problem } => { if let Some(state) = self.problems.get_mut(problem) { state.face_up = true; } } GroundEvent::SolutionDrawn { player, card } => { // The deck draws from its end, matching the GR-S02 deal. self.solution_deck.pop(); if let Some(state) = self.players.get_mut(player) { state.hand.push(*card); } } GroundEvent::SolutionDiscarded { player, card } => { if let Some(state) = self.players.get_mut(player) { if let Some(pos) = state.hand.iter().position(|c| c == card) { state.hand.remove(pos); } } self.solution_discard.push(*card); } GroundEvent::ProblemClaimed { problem, by } => { if let Some(state) = self.problems.get_mut(problem) { state.claimed_by = Some(*by); } } GroundEvent::DeckReshuffled { order } => { self.solution_deck = order.clone(); self.solution_discard.clear(); } GroundEvent::DarvoTriggered { player } => { if let Some(state) = self.players.get_mut(player) { state.darvo = DarvoStage::Deny; } } GroundEvent::DarvoTargetChosen { player, target } => { self.darvo_targets.insert(*player, *target); } GroundEvent::ProblemDenied { problem } => { if let Some(state) = self.problems.get_mut(problem) { state.denied = true; state.face_up = false; } } GroundEvent::FocusPlaced { owner, target } => { self.focus.insert(*owner, *target); } GroundEvent::FocusFlippedToBlame { owner, target } => { self.focus.remove(owner); if let Some(state) = self.players.get_mut(target) { state.blame_from.push(*owner); } } GroundEvent::ProtectionGained { player } => { if let Some(state) = self.players.get_mut(player) { state.protection = state.protection.saturating_add(1); } } GroundEvent::DarvoAdvanced { player, stage } => { if let Some(state) = self.players.get_mut(player) { state.darvo = *stage; } } GroundEvent::DarvoEnded { player } => { if let Some(state) = self.players.get_mut(player) { state.darvo = DarvoStage::Off; } // GR-D06: an unresolved Focus token comes back. self.focus.remove(player); } GroundEvent::GameEnded { outcome } => { self.outcome = Some(outcome.clone()); self.step = RoundStep::End; } GroundEvent::StepAdvanced { step } => { self.step = *step; } GroundEvent::RoundEnded { round, next_lead } => { self.round = *round; self.lead = *next_lead; self.selections.clear(); self.ground_modes.clear(); self.ground_choices.clear(); self.support_responses.clear(); self.darvo_targets.clear(); for state in self.players.values_mut() { // GR-R03: the gate lift lasts one Select step only. state.freedom_gate_lifted = false; } for problem in self.problems.values_mut() { // GR-A11: OU protection lasts one round. problem.protected_this_round = false; } } } } } impl GroundState { /// GR-R06/R07: resolve in fixed step order, Lead first within a step. /// /// 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 { 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, ) { // 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.variant == Variant::H1ProblemStress && 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) { if self.solution_deck.is_empty() { if self.solution_discard.is_empty() { return; } let mut order = self.solution_discard.clone(); // Derived from the game seed and round, so the reshuffle is // a pure function of state (GameKernel K5). let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round))); rng.shuffle(&mut order); events.push(GroundEvent::DeckReshuffled { order }); self.fold(events.last().expect("just pushed")); } if let Some(card) = self.solution_deck.last().copied() { events.push(GroundEvent::SolutionDrawn { player, card }); self.fold(events.last().expect("just pushed")); } } /// GR-R08: Stress is already clamped on every application (U2), so /// End only triggers DARVO, rotates the Lead, and advances the Round. fn end_round_events(&self) -> Vec { let mut events = Vec::new(); // 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(); if self.variant == Variant::H1ProblemStress && 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 = self.players.keys().copied().collect(); let next_lead = seats .iter() .position(|s| *s == self.lead) .map_or(self.lead, |i| seats[(i + 1) % seats.len()]); // GR-R09: after Round 5's End the game ends and scoring applies. if self.round >= 5 { events.push(GroundEvent::GameEnded { outcome: self.score(), }); return events; } events.push(GroundEvent::RoundEnded { round: self.round + 1, next_lead, }); events.push(GroundEvent::StepAdvanced { step: RoundStep::Select, }); events } /// GR-E01: the Scenario threshold by player count (dataset 0.1). fn threshold(&self) -> u32 { match self.players.len() { 0..=2 => 5, 3..=4 => 7, _ => 9, } } /// GR-E01..E04: final scoring for the configured mode. fn score(&self) -> Outcome { // GR-E01/P03: a claimed Problem counts its printed value. let total: u32 = self .problems .values() .filter(|p| p.claimed_by.is_some()) .map(|p| u32::from(p.value)) .sum(); let threshold = self.threshold(); let group_success = total >= threshold; // GR-E03/T02: claimed value −1 per Blame token held. let personal: BTreeMap = self .players .keys() .map(|seat| { let claimed: i32 = self .problems .values() .filter(|p| p.claimed_by == Some(*seat)) .map(|p| i32::from(p.value)) .sum(); let blame = self.players[seat].blame_from.len() as i32; (*seat, claimed - blame) }) .collect(); let coalitions = self.coalitions(&personal); let (mastery, winners) = match self.mode { // GR-E02: one shared score; no individual winner. ScoringMode::SharedGround => { let blame: i32 = self .players .values() .map(|p| p.blame_from.len() as i32) .sum(); let denied = self.problems.values().filter(|p| p.denied).count() as i32; let claimed = self .problems .values() .filter(|p| p.claimed_by.is_some()) .count() as i32; let mastery = claimed - blame - denied; let winners = if group_success { self.players.keys().copied().collect() } else { Vec::new() }; (Some(mastery), winners) } // GR-E03: highest personal score, once the group qualifies. // Tiebreak: lower Stress, then more Bonds, then shared. ScoringMode::CommonProblem => { let winners = if group_success { self.best(personal.keys().copied().collect(), |seat| { ( personal[&seat], -i32::from(self.players[&seat].stress), self.bond_count(seat), ) }) } else { Vec::new() }; (None, winners) } // GR-E04: highest coalition. Tiebreak: lower combined // Stress, then fewer Blame tokens, then shared. ScoringMode::BondedCoalitions => { let winners = if group_success { let best = self.best((0..coalitions.len()).collect(), |i| { let c = &coalitions[i]; let stress: i32 = c .members .iter() .map(|m| i32::from(self.players[m].stress)) .sum(); let blame: i32 = c .members .iter() .map(|m| self.players[m].blame_from.len() as i32) .sum(); (c.score, -stress, -blame) }); let mut winners: Vec = best .into_iter() .flat_map(|i| coalitions[i].members.clone()) .collect(); winners.sort(); winners } else { Vec::new() }; (None, winners) } }; Outcome { total, threshold, group_success, personal, coalitions, mastery, winners, } } /// Every candidate tied on the ranking key, so a tie is a shared /// result rather than an arbitrary pick (GR-E03/E04). fn best(&self, candidates: Vec, key: impl Fn(T) -> K) -> Vec { let Some(top) = candidates.iter().map(|c| key(*c)).max() else { return Vec::new(); }; candidates.into_iter().filter(|c| key(*c) == top).collect() } fn bond_count(&self, seat: PlayerId) -> i32 { self.relations .iter() .filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat)) .count() as i32 } /// GR-E04: connected components over Bonds only; Rivalries do not /// connect, and an unbonded player is a coalition of one. fn coalitions(&self, personal: &BTreeMap) -> Vec { let mut remaining: Vec = self.players.keys().copied().collect(); let mut out = Vec::new(); while let Some(seed) = remaining.first().copied() { let mut members = vec![seed]; let mut frontier = vec![seed]; remaining.retain(|p| *p != seed); while let Some(current) = frontier.pop() { let neighbours: Vec = self .relations .iter() .filter(|(_, rel)| **rel == Relation::Bond) .filter_map(|(pair, _)| { if pair.0 == current { Some(pair.1) } else if pair.1 == current { Some(pair.0) } else { None } }) .filter(|n| remaining.contains(n)) .collect(); for n in neighbours { remaining.retain(|p| *p != n); members.push(n); frontier.push(n); } } members.sort(); let score = members.iter().map(|m| personal[m]).sum(); out.push(Coalition { members, score }); } out } /// GR-A11/A12: the sub-choice must name something that exists and /// is in a state the choice can act on. fn check_ground_choice( &self, actor: PlayerId, choice: Option, ) -> Result<(), Rejection> { let bad = |detail: String| Rejection::Game { code: "bad-choice".into(), detail, }; let problem = |id: u32| { self.problems .get(&id) .ok_or_else(|| bad(format!("no Problem {id}"))) }; match choice { None => Ok(()), // GR-A11: only a Denied Problem can be restored. Some(GroundChoice::RestoreProblem { problem: id }) => { if !problem(id)?.denied { return Err(bad(format!("GR-A11: Problem {id} is not Denied"))); } Ok(()) } // GR-A11: only a face-up Problem can be protected. Some(GroundChoice::ProtectProblem { problem: id }) => { if !problem(id)?.face_up || problem(id)?.denied { return Err(bad(format!("GR-A11: Problem {id} is not face up"))); } Ok(()) } // GR-A11: the cancelled Attack must actually target us. Some(GroundChoice::CancelAttack { attacker }) => { let aimed_here = self .selections .get(&attacker) .is_some_and(|s| s.action == Action::Attack && s.target == Some(actor)); if !aimed_here { return Err(bad(format!( "GR-A11: {attacker} is not attacking this player" ))); } Ok(()) } // GR-A12/T02: the Blame token must be in front of us. Some(GroundChoice::RemoveBlame { owner }) => { let held = self .players .get(&actor) .is_some_and(|p| p.blame_from.contains(&owner)); if !held { return Err(bad(format!("GR-T02: no Blame token from {owner} here"))); } Ok(()) } // GR-A12: the relation must exist and involve us. Some(GroundChoice::BreakRelation { with }) => { if self.relation_between(actor, with).is_none() { return Err(bad(format!("GR-A12: no relation with {with}"))); } Ok(()) } Some(GroundChoice::RejectReverse) => Ok(()), } } /// GR-A13 targeting legality, shared by every Action. fn check_targeting( &self, actor: PlayerId, action: Action, target: Option, problem: Option, ) -> Result<(), Rejection> { let bad = |detail: String| Rejection::Game { code: "bad-target".into(), detail, }; if action.requires_player_target() { let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?; if target == actor { return Err(bad( "GR-A13: SUPPORT and ATTACK target another player".into() )); } if !self.players.contains_key(&target) { return Err(bad(format!("GR-A13: player {target} is not in this game"))); } } else if target.is_some() { return Err(bad(format!("GR-A13: {action:?} takes no player target"))); } if action.requires_problem_target() { let problem = problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?; let target = self .problems .get(&problem) .ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?; match action { // GR-A13: INVESTIGATE targets a hidden Problem. Action::Investigate if target.face_up => { return Err(bad(format!("GR-A13: Problem {problem} is already face up"))); } // GR-A13: SOLVE targets a face-up, non-Denied Problem. Action::Solve if !target.face_up || target.denied => { return Err(bad(format!( "GR-A13: Problem {problem} is not a face-up, non-Denied Problem" ))); } // 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. #[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 { let seats = setup.players; let expected = format!("standard-{seats}p"); if setup.preset != expected { return Err(format!( "preset {:?} does not match {seats} players (expected {expected:?})", setup.preset )); } // 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. let dealt = crate::edition::deal("SCN_01", 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 = 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, }, ) }) .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, // 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. variant: Variant::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 { step.args .get(key) .and_then(|v| v.as_str().map(str::to_string)) .ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd)) }; let arg_u64 = |key: &str| -> Option { step.args.get(key).and_then(|v| v.as_u64()) }; let command = match step.cmd.as_str() { "select_action" => { let action = Action::parse(&arg_str("action")?)?; let target = match step.args.get("target") { Some(_) => match parse_actor(&arg_str("target")?)? { Actor::Player(id) => Some(id), Actor::System => return Err("target may not be SYSTEM".into()), }, None => None, }; GroundCommand::SelectAction { action, target, problem: arg_u64("problem").map(|p| p as u32), } } "spend_freedom" => GroundCommand::SpendFreedom, "choose_ground_mode" => GroundCommand::ChooseGroundMode { mode: GroundMode::parse(&arg_str("mode")?)?, choice: match step.args.get("choice") { Some(_) => Some(GroundChoice::parse( &arg_str("choice")?, arg_u64("problem").or_else(|| arg_u64("seat")), )?), None => None, }, }, "choose_darvo_target" => GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: arg_u64("problem").map(|p| p as u32), player: match step.args.get("target") { Some(_) => match parse_actor(&arg_str("target")?)? { Actor::Player(seat) => Some(seat), Actor::System => return Err("target may not be SYSTEM".into()), }, None => None, }, }, }, "respond_to_support" => GroundCommand::RespondToSupport { response: SupportResponse::parse(&arg_str("response")?)?, }, "reveal" => GroundCommand::Reveal, "resolve" => GroundCommand::Resolve, "end_round" => GroundCommand::EndRound, other => return Err(format!("unknown command {other:?}")), }; Ok((actor, command)) } fn round(&self) -> u8 { self.round } } #[cfg(test)] 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.variant = variant; s } /// **The load-bearing control.** A variant system that perturbs /// the baseline invalidates every measurement this repo has. #[test] fn baseline_is_bit_for_bit_what_it_was() { for players in [2u8, 3, 6] { for seed in 0..8u64 { let base = setup(players, Variant::Baseline, seed); let mut default_built = GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) .expect("setup"); // Untouched: whatever `setup` produces IS baseline. assert_eq!(default_built.variant, Variant::Baseline); default_built.variant = Variant::Baseline; assert_eq!( cb_events::state_hash_hex(&base), cb_events::state_hash_hex(&default_built), "{players}p seed {seed}: selecting the baseline changed it" ); } } } /// **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 = 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 = 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 { let mut s = setup(3, variant, 3); let seats: Vec = 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" ); } /// **`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 = 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" ); } } } 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, variant: Variant::Baseline, outcome: None, seed: 0, } } /// Regression: relation keys must serialize as JSON object keys. /// With a tuple key, `state_hash` panicked on any state holding a /// relation — that is, on almost every real game state. #[test] fn state_with_relations_hashes() { let mut state = tiny_state(); state .relations .insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond); let hash = state_hash_hex(&state); assert_eq!(hash.len(), 64); // GR-O05: the key is canonically ordered, so either construction // order yields the same state and the same hash. let mut mirrored = tiny_state(); mirrored .relations .insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond); assert_eq!(hash, state_hash_hex(&mirrored)); } fn setup_3p(seed: u64) -> GroundState { GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: BTreeMap::new(), }, seed, ) .unwrap() } /// GR-S02/S04: every seat starts at Stress 2 with two dealt cards, /// and the deck loses exactly what was dealt. #[test] fn setup_deals_per_gr_s02_and_s04() { let state = setup_3p(42); assert_eq!(state.players.len(), 3); assert_eq!(state.round, 1); for player in state.players.values() { assert_eq!(player.stress, 2); assert!(player.freedom_ready); assert_eq!(player.darvo, DarvoStage::Off); assert_eq!(player.hand.len(), 2); } assert_eq!(state.solution_deck.len(), 24 - 6); // GR-S01 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 = (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) -> 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` 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: **~182k–212k ev/s in debug**, /// **~2.4M–3.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 = 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 = 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 = 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.45–0.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 { 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." ); } }