//! 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. #[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, /// GR-R09: set once the game has ended and scoring has run. pub outcome: Option, /// GR-S04/U4: retained so a deck reshuffle stays a pure function of /// state, keeping `validate` deterministic without holding RNG state. pub seed: u64, } /// GR-E02..E04: the three scoring modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ScoringMode { /// GR-E02, co-op: one shared score against the threshold. SharedGround, /// GR-E03, semi-co-op: personal scores once the group qualifies. CommonProblem, /// GR-E04: Bond networks score together. BondedCoalitions, } /// GR-E01..E04: the final scoring result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Outcome { /// GR-E01: summed printed values of all claimed Problems. pub total: u32, pub threshold: u32, pub group_success: bool, /// GR-E03: claimed value −1 per Blame held. pub personal: BTreeMap, /// GR-E04: each Bond-connected group and its combined score. pub coalitions: Vec, /// GR-E02: only meaningful in SHARED GROUND. pub mastery: Option, pub winners: Vec, } /// GR-E04: one Bond-connected group. Unbonded players are solo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Coalition { pub members: Vec, pub score: i32, } /// GR-D03/D04: what a DARVO stage acts on this round. DENY names a /// Problem, ATTACK names a player; REVERSE takes its target from the /// Focus token placed by the ATTACK stage. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct DarvoTarget { pub problem: Option, pub player: Option, } /// GR-A10..A12: the three GROUND modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum GroundMode { /// Ground & Restate (GR-A10). Gr, /// Observe & Uphold (GR-A11). Ou, /// Name & Decide (GR-A12). Nd, } /// GR-A11/A12: the sub-choice a GROUND—OU or GROUND—ND player makes /// alongside the mode. GROUND—GR takes none. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "choice")] pub enum GroundChoice { /// GR-A11: restore one Denied Problem. RestoreProblem { problem: u32 }, /// GR-A11: cancel one Attack targeting this player this round. CancelAttack { attacker: PlayerId }, /// GR-A11: protect one face-up Problem from Deny this round. ProtectProblem { problem: u32 }, /// GR-A12: remove one Blame token from this player. RemoveBlame { owner: PlayerId }, /// GR-A12: break one relation involving this player. BreakRelation { with: PlayerId }, /// GR-A12: reject one Reverse targeting this player this round. RejectReverse, } impl GroundChoice { /// GR-A11/A12: each choice belongs to exactly one mode. fn mode(self) -> GroundMode { match self { GroundChoice::RestoreProblem { .. } | GroundChoice::CancelAttack { .. } | GroundChoice::ProtectProblem { .. } => GroundMode::Ou, GroundChoice::RemoveBlame { .. } | GroundChoice::BreakRelation { .. } | GroundChoice::RejectReverse => GroundMode::Nd, } } #[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. /// /// Step 3 (active DARVO stages) is not yet implemented — the DARVO /// machine lands with GR-D02..D07 and no scenario claims coverage of /// it. GROUND—OU and GROUND—ND (GR-A11/A12) each offer a three-way /// choice that needs its own command and are likewise pending. fn resolution_events(&self) -> Vec { let mut events = Vec::new(); // A working copy so slot counts and Stress reflect earlier // effects within the same resolution, per GR-R07 ordering. let mut work = self.clone(); // Relations as they stood before this round's Support step // (GR-L05): a Bond formed now is not "pre-existing". let pre_existing = self.relations.clone(); // GR-A11: Attacks cancelled by a GROUND—OU choice this round. let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> = std::collections::BTreeSet::new(); // Step 1 — GROUND (GR-A10..A12). for actor in self.seat_order() { if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) { continue; } match self.ground_modes.get(&actor) { // GR-A10: Ground & Restate — self −2 Stress, Freedom READY. Some(GroundMode::Gr) => { let stress = work.stress_after(actor, -2); events.push(GroundEvent::StressSet { player: actor, stress, }); work.fold(events.last().expect("just pushed")); if !work.players[&actor].freedom_ready { events.push(GroundEvent::FreedomReadied { player: actor }); work.fold(events.last().expect("just pushed")); } } // GR-A11: Observe & Uphold. Some(GroundMode::Ou) => match work.ground_choices.get(&actor).copied() { Some(GroundChoice::RestoreProblem { problem }) => { events.push(GroundEvent::ProblemRestored { problem }); work.fold(events.last().expect("just pushed")); } Some(GroundChoice::ProtectProblem { problem }) => { events.push(GroundEvent::ProblemProtected { problem }); work.fold(events.last().expect("just pushed")); } // Consumed in the Attack step below. Some(GroundChoice::CancelAttack { attacker }) => { ou_cancels.insert((attacker, actor)); } _ => {} }, // GR-A12: Name & Decide. Some(GroundMode::Nd) => match work.ground_choices.get(&actor).copied() { Some(GroundChoice::RemoveBlame { owner }) => { events.push(GroundEvent::BlameRemoved { player: actor, owner, }); work.fold(events.last().expect("just pushed")); } Some(GroundChoice::BreakRelation { with }) => { events.push(GroundEvent::RelationBroken { pair: Pair::new(actor, with), }); work.fold(events.last().expect("just pushed")); } // GR-D05: consumed by the Reverse stage. _ => {} }, None => {} } } // Step 2 — Support (GR-A03/A04/A05). for actor in self.seat_order() { let Some(selection) = self.selections.get(&actor) else { continue; }; if selection.action != Action::Support { continue; } let Some(target) = selection.target else { continue; }; match pre_existing.get(&Pair::new(actor, target)).copied() { // GR-A04: Support through an existing Bond. Some(Relation::Bond) => { let stress = work.stress_after(target, -2); events.push(GroundEvent::StressSet { player: target, stress, }); work.fold(events.last().expect("just pushed")); // GR-F04: a Bond Support readies the target's token. if !work.players[&target].freedom_ready { events.push(GroundEvent::FreedomReadied { player: target }); work.fold(events.last().expect("just pushed")); } } // GR-A05: Support through a Rivalry — −1 Stress, then // the target flips it to a Bond or breaks it. Some(Relation::Rivalry) => { let stress = work.stress_after(target, -1); events.push(GroundEvent::StressSet { player: target, stress, }); work.fold(events.last().expect("just pushed")); let pair = Pair::new(actor, target); match work.support_responses.get(&target).copied() { Some(SupportResponse::FlipToBond) => { events.push(GroundEvent::RelationFormed { pair, relation: Relation::Bond, }); work.fold(events.last().expect("just pushed")); } Some(SupportResponse::BreakRivalry) => { events.push(GroundEvent::RelationBroken { pair }); work.fold(events.last().expect("just pushed")); } // No answer: the Rivalry stands. _ => {} } } // GR-A03/L02: no relation — −1 Stress, and a Bond forms // only if the target accepts and both have a free slot. None => { let stress = work.stress_after(target, -1); events.push(GroundEvent::StressSet { player: target, stress, }); work.fold(events.last().expect("just pushed")); let accepted = work.support_responses.get(&target).copied() == Some(SupportResponse::AcceptBond); if accepted && work.has_free_slot(actor) && work.has_free_slot(target) { events.push(GroundEvent::RelationFormed { pair: Pair::new(actor, target), relation: Relation::Bond, }); work.fold(events.last().expect("just pushed")); } } } } // Step 3 — active DARVO stages (GR-D02..D07). for owner in self.seat_order() { let stage = work.players[&owner].darvo; if stage == DarvoStage::Off { continue; } // GR-D06 + GR-A04: a Support through a Bond that existed // before this round's Support step cancels the current stage // and ends the sequence (GR-L05). let bond_support = self.selections.iter().any(|(seat, sel)| { sel.action == Action::Support && sel.target == Some(owner) && pre_existing.get(&Pair::new(*seat, owner)) == Some(&Relation::Bond) }); if bond_support { events.push(GroundEvent::DarvoEnded { player: owner }); work.fold(events.last().expect("just pushed")); continue; } match stage { // GR-D03: turn one eligible Problem face down and Deny // it. Under the U3 default, no legal target is a no-op // and the sequence still advances. DarvoStage::Deny => { if let Some(problem) = work.darvo_targets.get(&owner).and_then(|t| t.problem) { let eligible = work.problems.get(&problem).is_some_and(|p| { p.face_up && !p.denied && p.claimed_by.is_none() && !p.protected_this_round }); if eligible { events.push(GroundEvent::ProblemDenied { problem }); work.fold(events.last().expect("just pushed")); } } } // GR-D04: one extra Attack under the normal relation // rules, then place the Focus token beside the target — // even if the Attack was cancelled. DarvoStage::Attack => { if let Some(target) = work.darvo_targets.get(&owner).and_then(|t| t.player) { work.resolve_attack(owner, target, &ou_cancels, &mut events); events.push(GroundEvent::FocusPlaced { owner, target }); work.fold(events.last().expect("just pushed")); } } // GR-D05: targets the Focus holder. DarvoStage::Reverse => { if let Some(target) = work.focus.get(&owner).copied() { // GR-A12: the target's GROUND—ND may reject it. let rejected = work.ground_choices.get(&target) == Some(&GroundChoice::RejectReverse); if !rejected { events.push(GroundEvent::FocusFlippedToBlame { owner, target }); work.fold(events.last().expect("just pushed")); let stress = work.stress_after(target, 1); events.push(GroundEvent::StressSet { player: target, stress, }); work.fold(events.last().expect("just pushed")); events.push(GroundEvent::ProtectionGained { player: owner }); work.fold(events.last().expect("just pushed")); } // U5: rejected or not, the owner still takes −2 // and the sequence ends. let stress = work.stress_after(owner, -2); events.push(GroundEvent::StressSet { player: owner, stress, }); work.fold(events.last().expect("just pushed")); } } DarvoStage::Off => {} } // GR-A10 + GR-D06: GROUND—GR ends the sequence after the // current stage resolves. GR-D05: REVERSE ends it anyway. let ground_gr = work.ground_modes.get(&owner) == Some(&GroundMode::Gr); if ground_gr || stage == DarvoStage::Reverse { events.push(GroundEvent::DarvoEnded { player: owner }); work.fold(events.last().expect("just pushed")); } else { // GR-D02: one stage per consecutive round. let next = match stage { DarvoStage::Deny => DarvoStage::Attack, _ => DarvoStage::Reverse, }; events.push(GroundEvent::DarvoAdvanced { player: owner, stage: next, }); work.fold(events.last().expect("just pushed")); } } // Step 5 — Attack (GR-A06..A09). for actor in self.seat_order() { let Some(selection) = self.selections.get(&actor) else { continue; }; if selection.action != Action::Attack { continue; } let Some(target) = selection.target else { continue; }; work.resolve_attack(actor, target, &ou_cancels, &mut events); } // Step 4 — INVESTIGATE (GR-A01). for actor in self.seat_order() { let Some(selection) = self.selections.get(&actor) else { continue; }; if selection.action != Action::Investigate { continue; } // Reveal the chosen Problem if it is still hidden and not // Denied; otherwise the draw happens on its own. if let Some(problem) = selection.problem { let eligible = work .problems .get(&problem) .is_some_and(|p| !p.face_up && !p.denied); if eligible { events.push(GroundEvent::ProblemRevealed { problem }); work.fold(events.last().expect("just pushed")); } } work.draw_solution(actor, &mut events); } // Step 6 — SOLVE (GR-A02). for actor in self.seat_order() { let Some(selection) = self.selections.get(&actor) else { continue; }; if selection.action != Action::Solve { continue; } let Some(problem) = selection.problem else { continue; }; let Some(target) = work.problems.get(&problem) else { continue; }; // GR-A02: an earlier resolver this round already claimed it, // so no Solution is spent and nothing happens. if target.claimed_by.is_some() || target.denied || !target.face_up { continue; } let required = target.suit; let Some(card) = work.players[&actor] .hand .iter() .find(|c| c.suit == required) .copied() else { continue; }; events.push(GroundEvent::SolutionDiscarded { player: actor, card, }); work.fold(events.last().expect("just pushed")); events.push(GroundEvent::ProblemClaimed { problem, by: actor }); work.fold(events.last().expect("just pushed")); } events.push(GroundEvent::StepAdvanced { step: RoundStep::Resolve, }); events } /// GR-A06..A09: one Attack under the normal relation rules. Shared /// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's /// extra Attack (GR-D04). fn resolve_attack( &mut self, attacker: PlayerId, target: PlayerId, ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>, events: &mut Vec, ) { // GR-A09 under the U8 default: a GROUND—OU cancellation is // chosen at step 1 and applies first, so Protection is only // consumed when it is what actually cancels. if ou_cancels.contains(&(attacker, target)) { return; } // GR-A09/T01: Protection absorbs the Attack entirely. if self.players[&target].protection > 0 { events.push(GroundEvent::AttackCancelled { attacker, target }); self.fold(events.last().expect("just pushed")); return; } let pair = Pair::new(attacker, target); let (delta, after) = match self.relation_between(attacker, target) { // GR-A07: through a Bond — +2 and the Bond flips. Some(Relation::Bond) => ( 2, Some(GroundEvent::RelationFormed { pair, relation: Relation::Rivalry, }), ), // GR-A08: through a Rivalry — +2 and it breaks. Some(Relation::Rivalry) => (2, Some(GroundEvent::RelationBroken { pair })), // GR-A06: no relation — +1, and a Rivalry forms without // consent if both endpoints have a slot (GR-L01/L03). None => { let forms = self.has_free_slot(attacker) && self.has_free_slot(target); ( 1, forms.then_some(GroundEvent::RelationFormed { pair, relation: Relation::Rivalry, }), ) } }; let stress = self.stress_after(target, delta); events.push(GroundEvent::StressSet { player: target, stress, }); self.fold(events.last().expect("just pushed")); if let Some(event) = after { events.push(event); self.fold(events.last().expect("just pushed")); } } /// GR-A01: draw one Solution, reshuffling the discard first if the /// deck is empty (the U4 default). The reshuffled order travels in /// the event so replay never re-derives it. fn draw_solution(&mut self, player: PlayerId, events: &mut Vec) { if self.solution_deck.is_empty() { if self.solution_discard.is_empty() { return; } let mut order = self.solution_discard.clone(); // Derived from the game seed and round, so the reshuffle is // a pure function of state (GameKernel K5). let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round))); rng.shuffle(&mut order); events.push(GroundEvent::DeckReshuffled { order }); self.fold(events.last().expect("just pushed")); } if let Some(card) = self.solution_deck.last().copied() { events.push(GroundEvent::SolutionDrawn { player, card }); self.fold(events.last().expect("just pushed")); } } /// GR-R08: Stress is already clamped on every application (U2), so /// End only triggers DARVO, rotates the Lead, and advances the Round. fn end_round_events(&self) -> Vec { let mut events = Vec::new(); // GR-D01: Stress 5 with the marker OFF starts a sequence. In // Lead order, so two simultaneous triggers are ordered (U9). for seat in self.seat_order() { let player = &self.players[&seat]; if player.stress == 5 && player.darvo == DarvoStage::Off { events.push(GroundEvent::DarvoTriggered { player: seat }); } } let seats: Vec = self.players.keys().copied().collect(); let next_lead = seats .iter() .position(|s| *s == self.lead) .map_or(self.lead, |i| seats[(i + 1) % seats.len()]); // GR-R09: after Round 5's End the game ends and scoring applies. if self.round >= 5 { events.push(GroundEvent::GameEnded { outcome: self.score(), }); return events; } events.push(GroundEvent::RoundEnded { round: self.round + 1, next_lead, }); events.push(GroundEvent::StepAdvanced { step: RoundStep::Select, }); events } /// GR-E01: the Scenario threshold by player count (dataset 0.1). fn threshold(&self) -> u32 { match self.players.len() { 0..=2 => 5, 3..=4 => 7, _ => 9, } } /// GR-E01..E04: final scoring for the configured mode. fn score(&self) -> Outcome { // GR-E01/P03: a claimed Problem counts its printed value. let total: u32 = self .problems .values() .filter(|p| p.claimed_by.is_some()) .map(|p| u32::from(p.value)) .sum(); let threshold = self.threshold(); let group_success = total >= threshold; // GR-E03/T02: claimed value −1 per Blame token held. let personal: BTreeMap = self .players .keys() .map(|seat| { let claimed: i32 = self .problems .values() .filter(|p| p.claimed_by == Some(*seat)) .map(|p| i32::from(p.value)) .sum(); let blame = self.players[seat].blame_from.len() as i32; (*seat, claimed - blame) }) .collect(); let coalitions = self.coalitions(&personal); let (mastery, winners) = match self.mode { // GR-E02: one shared score; no individual winner. ScoringMode::SharedGround => { let blame: i32 = self .players .values() .map(|p| p.blame_from.len() as i32) .sum(); let denied = self.problems.values().filter(|p| p.denied).count() as i32; let claimed = self .problems .values() .filter(|p| p.claimed_by.is_some()) .count() as i32; let mastery = claimed - blame - denied; let winners = if group_success { self.players.keys().copied().collect() } else { Vec::new() }; (Some(mastery), winners) } // GR-E03: highest personal score, once the group qualifies. // Tiebreak: lower Stress, then more Bonds, then shared. ScoringMode::CommonProblem => { let winners = if group_success { self.best(personal.keys().copied().collect(), |seat| { ( personal[&seat], -i32::from(self.players[&seat].stress), self.bond_count(seat), ) }) } else { Vec::new() }; (None, winners) } // GR-E04: highest coalition. Tiebreak: lower combined // Stress, then fewer Blame tokens, then shared. ScoringMode::BondedCoalitions => { let winners = if group_success { let best = self.best((0..coalitions.len()).collect(), |i| { let c = &coalitions[i]; let stress: i32 = c .members .iter() .map(|m| i32::from(self.players[m].stress)) .sum(); let blame: i32 = c .members .iter() .map(|m| self.players[m].blame_from.len() as i32) .sum(); (c.score, -stress, -blame) }); let mut winners: Vec = best .into_iter() .flat_map(|i| coalitions[i].members.clone()) .collect(); winners.sort(); winners } else { Vec::new() }; (None, winners) } }; Outcome { total, threshold, group_success, personal, coalitions, mastery, winners, } } /// Every candidate tied on the ranking key, so a tie is a shared /// result rather than an arbitrary pick (GR-E03/E04). fn best(&self, candidates: Vec, key: impl Fn(T) -> K) -> Vec { let Some(top) = candidates.iter().map(|c| key(*c)).max() else { return Vec::new(); }; candidates.into_iter().filter(|c| key(*c) == top).collect() } fn bond_count(&self, seat: PlayerId) -> i32 { self.relations .iter() .filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat)) .count() as i32 } /// GR-E04: connected components over Bonds only; Rivalries do not /// connect, and an unbonded player is a coalition of one. fn coalitions(&self, personal: &BTreeMap) -> Vec { let mut remaining: Vec = self.players.keys().copied().collect(); let mut out = Vec::new(); while let Some(seed) = remaining.first().copied() { let mut members = vec![seed]; let mut frontier = vec![seed]; remaining.retain(|p| *p != seed); while let Some(current) = frontier.pop() { let neighbours: Vec = self .relations .iter() .filter(|(_, rel)| **rel == Relation::Bond) .filter_map(|(pair, _)| { if pair.0 == current { Some(pair.1) } else if pair.1 == current { Some(pair.0) } else { None } }) .filter(|n| remaining.contains(n)) .collect(); for n in neighbours { remaining.retain(|p| *p != n); members.push(n); frontier.push(n); } } members.sort(); let score = members.iter().map(|m| personal[m]).sum(); out.push(Coalition { members, score }); } out } /// GR-A11/A12: the sub-choice must name something that exists and /// is in a state the choice can act on. fn check_ground_choice( &self, actor: PlayerId, choice: Option, ) -> Result<(), Rejection> { let bad = |detail: String| Rejection::Game { code: "bad-choice".into(), detail, }; let problem = |id: u32| { self.problems .get(&id) .ok_or_else(|| bad(format!("no Problem {id}"))) }; match choice { None => Ok(()), // GR-A11: only a Denied Problem can be restored. Some(GroundChoice::RestoreProblem { problem: id }) => { if !problem(id)?.denied { return Err(bad(format!("GR-A11: Problem {id} is not Denied"))); } Ok(()) } // GR-A11: only a face-up Problem can be protected. Some(GroundChoice::ProtectProblem { problem: id }) => { if !problem(id)?.face_up || problem(id)?.denied { return Err(bad(format!("GR-A11: Problem {id} is not face up"))); } Ok(()) } // GR-A11: the cancelled Attack must actually target us. Some(GroundChoice::CancelAttack { attacker }) => { let aimed_here = self .selections .get(&attacker) .is_some_and(|s| s.action == Action::Attack && s.target == Some(actor)); if !aimed_here { return Err(bad(format!( "GR-A11: {attacker} is not attacking this player" ))); } Ok(()) } // GR-A12/T02: the Blame token must be in front of us. Some(GroundChoice::RemoveBlame { owner }) => { let held = self .players .get(&actor) .is_some_and(|p| p.blame_from.contains(&owner)); if !held { return Err(bad(format!("GR-T02: no Blame token from {owner} here"))); } Ok(()) } // GR-A12: the relation must exist and involve us. Some(GroundChoice::BreakRelation { with }) => { if self.relation_between(actor, with).is_none() { return Err(bad(format!("GR-A12: no relation with {with}"))); } Ok(()) } Some(GroundChoice::RejectReverse) => Ok(()), } } /// GR-A13 targeting legality, shared by every Action. fn check_targeting( &self, actor: PlayerId, action: Action, target: Option, problem: Option, ) -> Result<(), Rejection> { let bad = |detail: String| Rejection::Game { code: "bad-target".into(), detail, }; if action.requires_player_target() { let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?; if target == actor { return Err(bad( "GR-A13: SUPPORT and ATTACK target another player".into() )); } if !self.players.contains_key(&target) { return Err(bad(format!("GR-A13: player {target} is not in this game"))); } } else if target.is_some() { return Err(bad(format!("GR-A13: {action:?} takes no player target"))); } if action.requires_problem_target() { let problem = problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?; let target = self .problems .get(&problem) .ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?; match action { // GR-A13: INVESTIGATE targets a hidden Problem. Action::Investigate if target.face_up => { return Err(bad(format!("GR-A13: Problem {problem} is already face up"))); } // GR-A13: SOLVE targets a face-up, non-Denied Problem. Action::Solve if !target.face_up || target.denied => { return Err(bad(format!( "GR-A13: Problem {problem} is not a face-up, non-Denied Problem" ))); } _ => {} } } else if problem.is_some() { return Err(bad(format!("GR-A13: {action:?} takes no Problem target"))); } Ok(()) } } /// GR-S01: hidden-Problem priorities admitted per player count. #[cfg(feature = "scenarios")] fn problem_priorities(players: u8) -> Result { match players { 2 => Ok(2), 3..=4 => Ok(3), 5..=6 => Ok(4), other => Err(format!("GR-S01: unsupported player count {other}")), } } /// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order /// before the seeded shuffle. #[cfg(feature = "scenarios")] fn core_solution_deck() -> Vec { [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change] .into_iter() .flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6)) .collect() } #[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 )); } let priorities = problem_priorities(seats)?; let mut rng = ChaChaRng::from_seed(Seed(seed)); // GR-S04: shuffle first, then deal, so the deal is seed-derived. let mut deck = core_solution_deck(); rng.shuffle(&mut deck); // GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards. let mut players = BTreeMap::new(); for seat in 0..seats { let hand = deck.split_off(deck.len() - 2); players.insert( PlayerId(seat), PlayerState { stress: 2, freedom_ready: true, freedom_gate_lifted: false, darvo: DarvoStage::Off, hand, protection: 0, blame_from: vec![], }, ); } // GR-S01: priority 1 is the Surface Problem, face up; the rest // start face down. let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]; let problems = (1..=u32::from(priorities)) .map(|priority| { ( priority, ProblemState { suit: suits[(priority as usize - 1) % suits.len()], value: priority as u8, face_up: priority == 1, denied: false, claimed_by: None, protected_this_round: false, }, ) }) .collect(); // GR-S03: seeded-random Lead, Round 1. let lead = PlayerId(rng.draw(u32::from(seats)) as u8); Ok(GroundState { round: 1, lead, players, relations: BTreeMap::new(), problems, solution_deck: deck, solution_discard: vec![], focus: BTreeMap::new(), step: RoundStep::Select, selections: BTreeMap::new(), ground_modes: BTreeMap::new(), ground_choices: BTreeMap::new(), support_responses: BTreeMap::new(), darvo_targets: BTreeMap::new(), mode: ScoringMode::SharedGround, outcome: None, seed, }) } fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> { let actor = parse_actor(&step.actor)?; let arg_str = |key: &str| -> Result { step.args .get(key) .and_then(|v| v.as_str().map(str::to_string)) .ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd)) }; let arg_u64 = |key: &str| -> Option { step.args.get(key).and_then(|v| v.as_u64()) }; let command = match step.cmd.as_str() { "select_action" => { let action = Action::parse(&arg_str("action")?)?; let target = match step.args.get("target") { Some(_) => match parse_actor(&arg_str("target")?)? { Actor::Player(id) => Some(id), Actor::System => return Err("target may not be SYSTEM".into()), }, None => None, }; GroundCommand::SelectAction { action, target, problem: arg_u64("problem").map(|p| p as u32), } } "spend_freedom" => GroundCommand::SpendFreedom, "choose_ground_mode" => GroundCommand::ChooseGroundMode { mode: GroundMode::parse(&arg_str("mode")?)?, choice: match step.args.get("choice") { Some(_) => Some(GroundChoice::parse( &arg_str("choice")?, arg_u64("problem").or_else(|| arg_u64("seat")), )?), None => None, }, }, "choose_darvo_target" => GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: arg_u64("problem").map(|p| p as u32), player: match step.args.get("target") { Some(_) => match parse_actor(&arg_str("target")?)? { Actor::Player(seat) => Some(seat), Actor::System => return Err("target may not be SYSTEM".into()), }, None => None, }, }, }, "respond_to_support" => GroundCommand::RespondToSupport { response: SupportResponse::parse(&arg_str("response")?)?, }, "reveal" => GroundCommand::Reveal, "resolve" => GroundCommand::Resolve, "end_round" => GroundCommand::EndRound, other => return Err(format!("unknown command {other:?}")), }; Ok((actor, command)) } fn round(&self) -> u8 { self.round } } #[cfg(test)] mod tests { 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, outcome: None, seed: 0, } } /// Regression: relation keys must serialize as JSON object keys. /// With a tuple key, `state_hash` panicked on any state holding a /// relation — that is, on almost every real game state. #[test] fn state_with_relations_hashes() { let mut state = tiny_state(); state .relations .insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond); let hash = state_hash_hex(&state); assert_eq!(hash.len(), 64); // GR-O05: the key is canonically ordered, so either construction // order yields the same state and the same hash. let mut mirrored = tiny_state(); mirrored .relations .insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond); assert_eq!(hash, state_hash_hex(&mirrored)); } fn setup_3p(seed: u64) -> GroundState { GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: BTreeMap::new(), }, seed, ) .unwrap() } /// GR-S02/S04: every seat starts at Stress 2 with two dealt cards, /// and the deck loses exactly what was dealt. #[test] fn setup_deals_per_gr_s02_and_s04() { let state = setup_3p(42); assert_eq!(state.players.len(), 3); assert_eq!(state.round, 1); for player in state.players.values() { assert_eq!(player.stress, 2); assert!(player.freedom_ready); assert_eq!(player.darvo, DarvoStage::Off); assert_eq!(player.hand.len(), 2); } assert_eq!(state.solution_deck.len(), 24 - 6); // GR-S01: 3 players → priorities 1–3, priority 1 face up. assert_eq!(state.problems.len(), 3); assert!(state.problems[&1].face_up); assert!(!state.problems[&2].face_up); } /// GR-S03/S04: the same seed reproduces setup exactly; a different /// seed does not. #[test] fn setup_is_seed_deterministic() { assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42))); assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7))); } /// GR-R02: one selection per player per round. #[test] fn second_selection_is_a_duplicate() { let mut state = setup_3p(42); let cmd = GroundCommand::SelectAction { action: Action::Attack, target: Some(PlayerId(1)), problem: None, }; let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap(); for event in &events { state.fold(event); } assert_eq!( state.validate(Actor::Player(PlayerId(0)), &cmd), Err(Rejection::DuplicateCommand) ); } /// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending /// Freedom lifts it. #[test] fn stress_gate_blocks_until_freedom_is_spent() { let mut state = setup_3p(42); state.players.get_mut(&PlayerId(0)).unwrap().stress = 4; let support = GroundCommand::SelectAction { action: Action::Support, target: Some(PlayerId(1)), problem: None, }; let attack = GroundCommand::SelectAction { action: Action::Attack, target: Some(PlayerId(1)), problem: None, }; assert!(matches!( state.validate(Actor::Player(PlayerId(0)), &support), Err(Rejection::Game { ref code, .. }) if code == "stress-gate" )); // ATTACK is always admitted by the gate. assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok()); let spent = state .validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom) .unwrap(); for event in &spent { state.fold(event); } assert!(!state.players[&PlayerId(0)].freedom_ready); assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok()); } /// GR-A13: SUPPORT and ATTACK may not target their own player. #[test] fn self_targeting_is_rejected() { let state = setup_3p(42); let result = state.validate( Actor::Player(PlayerId(0)), &GroundCommand::SelectAction { action: Action::Attack, target: Some(PlayerId(0)), problem: None, }, ); assert!(matches!( result, Err(Rejection::Game { ref code, .. }) if code == "bad-target" )); } /// K7 on the real aggregate: hash stable across clones, sensitive to /// semantic change. #[test] 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::{ScenarioGame, Setup}; /// AM-6 reports events/second; the evidence file converts that to /// rounds and commands per second. Both divisors are pinned here so /// a change to the workload cannot silently rescale the metric. #[test] fn synthetic_round_shape_is_pinned() { let mut state = GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: BTreeMap::new(), }, 1, ) .unwrap(); let mut events = 0; let mut commands = 0; let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| { commands += 1; if let Ok(produced) = state.validate(actor, cmd) { for e in &produced { state.fold(e); } events += produced.len(); } }; for (seat, action, target) in [ (0u8, Action::Attack, Some(PlayerId(1))), (2, Action::Support, Some(PlayerId(1))), (1, Action::Ground, None), ] { run( &mut state, Actor::Player(PlayerId(seat)), &GroundCommand::SelectAction { action, target, problem: None, }, ); } run(&mut state, Actor::System, &GroundCommand::Reveal); run( &mut state, Actor::Player(PlayerId(1)), &GroundCommand::ChooseGroundMode { mode: GroundMode::Gr, choice: None, }, ); run(&mut state, Actor::System, &GroundCommand::Resolve); run(&mut state, Actor::System, &GroundCommand::EndRound); assert_eq!(commands, 7, "commands per synthetic round"); assert_eq!(events, 13, "events per synthetic round"); } } #[cfg(test)] mod replay_probe { use super::*; use cb_events::state_hash_hex; use cb_game_runtime::{ScenarioGame, Setup}; use std::time::Instant; fn fresh(seed: u64) -> GroundState { GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: BTreeMap::new(), }, seed, ) .unwrap() } fn record_round(state: &mut GroundState, log: &mut Vec) -> usize { let mut n = 0; let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| { if let Ok(produced) = state.validate(actor, cmd) { for e in &produced { state.fold(e); log.push(e.clone()); n += 1; } } }; for (seat, action, target) in [ (0u8, Action::Attack, Some(PlayerId(1))), (2, Action::Support, Some(PlayerId(1))), (1, Action::Ground, None), ] { run( state, Actor::Player(PlayerId(seat)), &GroundCommand::SelectAction { action, target, problem: None, }, ); } run(state, Actor::System, &GroundCommand::Reveal); run( state, Actor::Player(PlayerId(1)), &GroundCommand::ChooseGroundMode { mode: GroundMode::Gr, choice: None, }, ); run(state, Actor::System, &GroundCommand::Resolve); run(state, Actor::System, &GroundCommand::EndRound); n } /// AM-7: folding a 100k-event log back into state must stay well /// under the 5s budget, and must be linear in log length. #[test] fn replay_100k_events_is_linear_and_fast() { for target in [10_000usize, 100_000] { let mut log = Vec::with_capacity(target); let mut source = fresh(42); let mut games = 0u64; let mut stalls = 0; while log.len() < target { if source.outcome.is_some() { games += 1; source = fresh(42 + games); } if record_round(&mut source, &mut log) == 0 { stalls += 1; assert!(stalls < 10, "round produced no events; builder stalled"); } } let start = Instant::now(); let mut state = fresh(42); for event in &log { state.fold(event); } let hash = state_hash_hex(&state); let elapsed = start.elapsed(); println!( "replay {} events in {:?} ({:.0} events/s), hash {}", log.len(), elapsed, log.len() as f64 / elapsed.as_secs_f64(), &hash[..8] ); assert!(elapsed.as_secs_f64() < 5.0, "AM-7: 100k replay under 5s"); } } }