//! 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. 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-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<(PlayerId, PlayerId), Relation>, pub problems: BTreeMap, pub solution_deck: Vec, pub solution_discard: Vec, /// Focus placements: sequence owner → target (GR-T03). pub focus: BTreeMap, /// GR-R01: which of the four steps the round is in. pub step: RoundStep, /// GR-R02: face-down selections, hidden until Reveal. pub selections: BTreeMap, } /// GR-R01: Select → Reveal → Resolve → End. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RoundStep { Select, Reveal, Resolve, End, } /// GR-R02: one player's face-down choice, with its target where the /// Action requires one (GR-A13). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Selection { pub action: Action, pub target: Option, pub problem: Option, } /// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal /// (GR-R05), not at Select, so it is not part of the selection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Action { Investigate, Solve, Support, Attack, Ground, } impl Action { fn parse(raw: &str) -> Result { match raw { "INVESTIGATE" => Ok(Action::Investigate), "SOLVE" => Ok(Action::Solve), "SUPPORT" => Ok(Action::Support), "ATTACK" => Ok(Action::Attack), "GROUND" => Ok(Action::Ground), other => Err(format!("unknown action {other:?}")), } } /// GR-R03: the stress gate admits only ATTACK and GROUND. fn allowed_under_stress_gate(self) -> bool { matches!(self, Action::Attack | Action::Ground) } /// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and /// SOLVE target a Problem; GROUND targets neither at Select. fn requires_player_target(self) -> bool { matches!(self, Action::Support | Action::Attack) } fn requires_problem_target(self) -> bool { matches!(self, Action::Investigate | Action::Solve) } } /// Commands accepted by the GROUND aggregate. #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroundCommand { /// GR-R02: choose an Action face down. SelectAction { action: Action, target: Option, problem: Option, }, /// GR-R03: spend the READY Freedom token to bypass the stress gate. SpendFreedom, } /// 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, }, } 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 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> { 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 }]) } } } 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; } } } } } impl GroundState { /// 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")))?; if !self.problems.contains_key(&problem) { return Err(bad(format!("GR-A13: no Problem {problem}"))); } } else if problem.is_some() { return Err(bad(format!("GR-A13: {action:?} takes no Problem target"))); } Ok(()) } } /// GR-S01: hidden-Problem priorities admitted per player count. fn problem_priorities(players: u8) -> Result { match players { 2 => Ok(2), 3..=4 => Ok(3), 5..=6 => Ok(4), other => Err(format!("GR-S01: unsupported player count {other}")), } } /// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order /// before the seeded shuffle. fn core_solution_deck() -> Vec { [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change] .into_iter() .flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6)) .collect() } impl ScenarioGame for GroundState { /// GR-S01..S04. The `standard-Np` presets differ only in seat count; /// Problem content is scenario data, so the preset uses the canonical /// fixture below (suit cycling by priority) until scenario decks are /// modelled. fn setup(setup: &Setup, seed: u64) -> Result { let seats = setup.players; let expected = format!("standard-{seats}p"); if setup.preset != expected { return Err(format!( "preset {:?} does not match {seats} players (expected {expected:?})", setup.preset )); } let priorities = problem_priorities(seats)?; let mut rng = ChaChaRng::from_seed(Seed(seed)); // GR-S04: shuffle first, then deal, so the deal is seed-derived. let mut deck = core_solution_deck(); rng.shuffle(&mut deck); // GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards. let mut players = BTreeMap::new(); for seat in 0..seats { let hand = deck.split_off(deck.len() - 2); players.insert( PlayerId(seat), PlayerState { stress: 2, freedom_ready: true, freedom_gate_lifted: false, darvo: DarvoStage::Off, hand, protection: 0, blame_from: vec![], }, ); } // GR-S01: priority 1 is the Surface Problem, face up; the rest // start face down. let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]; let problems = (1..=u32::from(priorities)) .map(|priority| { ( priority, ProblemState { suit: suits[(priority as usize - 1) % suits.len()], value: priority as u8, face_up: priority == 1, denied: false, claimed_by: None, protected_this_round: false, }, ) }) .collect(); // GR-S03: seeded-random Lead, Round 1. let lead = PlayerId(rng.draw(u32::from(seats)) as u8); Ok(GroundState { round: 1, lead, players, relations: BTreeMap::new(), problems, solution_deck: deck, solution_discard: vec![], focus: BTreeMap::new(), step: RoundStep::Select, selections: BTreeMap::new(), }) } 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, 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(), } } 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)); } }