clay-borg/games/ground/src/lib.rs

990 lines
34 KiB
Rust
Raw Normal View History

//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
//! GameKernel K15K16). Every rule realized here names its GR-id in a doc
//! comment, giving a greppable rule→code→scenario chain.
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// GR-O02: per-player state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerState {
/// GR-F01: clamped 05.
pub stress: u8,
/// GR-F03: the Freedom token is READY until spent.
pub freedom_ready: bool,
/// GR-R03: set when Freedom is spent this round, lifting the stress
/// gate for this Select step only. Cleared at round End.
#[serde(default)]
pub freedom_gate_lifted: bool,
/// GR-D01/D02: OFF or the pending/active stage.
pub darvo: DarvoStage,
pub hand: Vec<SolutionCard>,
pub protection: u8,
/// Blame tokens in front of this player (GR-T02), keyed by owner.
pub blame_from: Vec<PlayerId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DarvoStage {
Off,
Deny,
Attack,
Reverse,
}
/// GR-O04: one Problem card's live state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProblemState {
pub suit: Suit,
pub value: u8,
pub face_up: bool,
pub denied: bool,
pub claimed_by: Option<PlayerId>,
/// GR-A11: protected from Deny this round by GROUND—OU.
pub protected_this_round: bool,
}
#[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,
}
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
/// GR-O05: an unordered player pair, canonically ordered low→high.
///
/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON
/// (GameKernel K7), and JSON object keys must be strings — a tuple key
/// makes `state_hash` fail on any state that holds a relation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Pair(pub PlayerId, pub PlayerId);
impl Pair {
pub fn new(a: PlayerId, b: PlayerId) -> Self {
if a <= b {
Pair(a, b)
} else {
Pair(b, a)
}
}
pub fn contains(&self, player: PlayerId) -> bool {
self.0 == player || self.1 == player
}
}
impl core::fmt::Display for Pair {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}-{}", self.0 .0, self.1 .0)
}
}
impl Serialize for Pair {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Pair {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let (a, b) = raw
.split_once('-')
.ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?;
let parse = |s: &str| {
s.parse::<u8>()
.map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
};
Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
}
}
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
/// collections only (GameKernel K6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroundState {
pub round: u8,
pub lead: PlayerId,
pub players: BTreeMap<PlayerId, PlayerState>,
/// Keyed by (low, high) player pair.
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
pub relations: BTreeMap<Pair, Relation>,
pub problems: BTreeMap<u32, ProblemState>,
pub solution_deck: Vec<SolutionCard>,
pub solution_discard: Vec<SolutionCard>,
/// Focus placements: sequence owner → target (GR-T03).
pub focus: BTreeMap<PlayerId, PlayerId>,
/// GR-R01: which of the four steps the round is in.
pub step: RoundStep,
/// GR-R02: face-down selections, hidden until Reveal.
pub selections: BTreeMap<PlayerId, Selection>,
}
/// GR-R01: Select → Reveal → Resolve → End.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RoundStep {
Select,
Reveal,
Resolve,
End,
}
/// GR-R02: one player's face-down choice, with its target where the
/// Action requires one (GR-A13).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
pub action: Action,
pub target: Option<PlayerId>,
pub problem: Option<u32>,
}
/// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal
/// (GR-R05), not at Select, so it is not part of the selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
Investigate,
Solve,
Support,
Attack,
Ground,
}
impl Action {
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"INVESTIGATE" => Ok(Action::Investigate),
"SOLVE" => Ok(Action::Solve),
"SUPPORT" => Ok(Action::Support),
"ATTACK" => Ok(Action::Attack),
"GROUND" => Ok(Action::Ground),
other => Err(format!("unknown action {other:?}")),
}
}
/// GR-R03: the stress gate admits only ATTACK and GROUND.
fn allowed_under_stress_gate(self) -> bool {
matches!(self, Action::Attack | Action::Ground)
}
/// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and
/// SOLVE target a Problem; GROUND targets neither at Select.
fn requires_player_target(self) -> bool {
matches!(self, Action::Support | Action::Attack)
}
fn requires_problem_target(self) -> bool {
matches!(self, Action::Investigate | Action::Solve)
}
}
/// Commands accepted by the GROUND aggregate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroundCommand {
/// GR-R02: choose an Action face down.
SelectAction {
action: Action,
target: Option<PlayerId>,
problem: Option<u32>,
},
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
SpendFreedom,
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
/// 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,
},
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
/// 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-D01: Stress 5 at End with the marker OFF.
DarvoTriggered {
player: PlayerId,
},
/// GR-R01: the round advanced to `step`.
StepAdvanced {
step: RoundStep,
},
/// GR-R08: Lead rotated and the Round marker advanced.
RoundEnded {
round: u8,
next_lead: PlayerId,
},
}
impl GroundState {
/// GR-R03: a player at Stress 45 is gated unless Freedom is spent.
/// Spending flips the token to SPENT, so the gate returns next round.
fn stress_gated(&self, player: &PlayerState) -> bool {
let _ = self;
player.stress >= 4 && !player.freedom_gate_lifted
}
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
self.relations.get(&Pair::new(a, b)).copied()
}
/// GR-L01: two relation slots per player.
fn has_free_slot(&self, player: PlayerId) -> bool {
let used = self
.relations
.keys()
.filter(|pair| pair.contains(player))
.count();
used < 2
}
/// GR-R07: resolution order starts at the Lead and continues
/// clockwise (ascending seat, wrapping).
fn seat_order(&self) -> Vec<PlayerId> {
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
seats[start..]
.iter()
.chain(&seats[..start])
.copied()
.collect()
}
/// GR-F01 with the U2 default: clamp on every application, so no
/// intermediate value escapes 05.
fn stress_after(&self, player: PlayerId, delta: i16) -> u8 {
let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress));
current.saturating_add(delta).clamp(0, 5) as u8
}
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
self.players.get(&id).ok_or(Rejection::Game {
code: "no-such-seat".into(),
detail: format!("player {id} is not in this game"),
})
}
}
impl Aggregate for GroundState {
type Command = GroundCommand;
type Event = GroundEvent;
fn validate(
&self,
actor: Actor,
command: &Self::Command,
) -> Result<Vec<Self::Event>, Rejection> {
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
// 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);
}
return Ok(self.resolution_events());
}
GroundCommand::EndRound => {
if self.step != RoundStep::Resolve || actor != Actor::System {
return Err(Rejection::NotAllowedNow);
}
return Ok(self.end_round_events());
}
_ => {}
}
let Actor::Player(id) = actor else {
return Err(Rejection::NotAllowedNow);
};
let player = self.player(id)?;
match command {
// GR-R02: one face-down choice per player, during Select only.
GroundCommand::SelectAction {
action,
target,
problem,
} => {
if self.step != RoundStep::Select {
return Err(Rejection::NotAllowedNow);
}
if self.selections.contains_key(&id) {
return Err(Rejection::DuplicateCommand);
}
if self.stress_gated(player) && !action.allowed_under_stress_gate() {
return Err(Rejection::Game {
code: "stress-gate".into(),
detail: "GR-R03: at Stress 45 only ATTACK or GROUND may be selected"
.into(),
});
}
self.check_targeting(id, *action, *target, *problem)?;
Ok(vec![GroundEvent::ActionSelected {
player: id,
selection: Selection {
action: *action,
target: *target,
problem: *problem,
},
}])
}
// GR-R03: spendable during Select, before Reveal, once.
GroundCommand::SpendFreedom => {
if self.step != RoundStep::Select {
return Err(Rejection::NotAllowedNow);
}
if !player.freedom_ready {
return Err(Rejection::Game {
code: "freedom-spent".into(),
detail: "GR-F03: the Freedom token is already SPENT".into(),
});
}
Ok(vec![GroundEvent::FreedomSpent { player: id }])
}
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
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;
}
}
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
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::DarvoTriggered { player } => {
if let Some(state) = self.players.get_mut(player) {
state.darvo = DarvoStage::Deny;
}
}
GroundEvent::StepAdvanced { step } => {
self.step = *step;
}
GroundEvent::RoundEnded { round, next_lead } => {
self.round = *round;
self.lead = *next_lead;
self.selections.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 {
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
///
/// Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE)
/// are not yet implemented — their Actions resolve as no-ops and no
/// scenario claims coverage of them.
fn resolution_events(&self) -> Vec<GroundEvent> {
let mut events = Vec::new();
// A working copy so slot counts and Stress reflect earlier
// effects within the same resolution, per GR-R07 ordering.
let mut work = self.clone();
// Relations as they stood before this round's Support step
// (GR-L05): a Bond formed now is not "pre-existing".
let pre_existing = self.relations.clone();
// 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. The target's
// flip-or-break choice needs a consent command and is
// deferred; only the Stress effect applies here.
Some(Relation::Rivalry) => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
}
// GR-A03: no relation. GR-L02 requires the target's
// consent to form a Bond, so no relation forms here.
None => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
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;
};
// GR-A09: Protection absorbs the Attack entirely.
if work.players[&target].protection > 0 {
events.push(GroundEvent::AttackCancelled {
attacker: actor,
target,
});
work.fold(events.last().expect("just pushed"));
continue;
}
let pair = Pair::new(actor, target);
match work.relation_between(actor, target) {
// GR-A07: Attack through a Bond — +2 and the Bond flips.
Some(Relation::Bond) => {
let stress = work.stress_after(target, 2);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
events.push(GroundEvent::RelationFormed {
pair,
relation: Relation::Rivalry,
});
work.fold(events.last().expect("just pushed"));
}
// GR-A08: Attack through a Rivalry — +2 and it breaks.
Some(Relation::Rivalry) => {
let stress = work.stress_after(target, 2);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
events.push(GroundEvent::RelationBroken { pair });
work.fold(events.last().expect("just pushed"));
}
// GR-A06: no relation — +1, and a Rivalry forms without
// consent if both endpoints have a slot (GR-L01/L03).
None => {
let stress = work.stress_after(target, 1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
if work.has_free_slot(actor) && work.has_free_slot(target) {
events.push(GroundEvent::RelationFormed {
pair,
relation: Relation::Rivalry,
});
work.fold(events.last().expect("just pushed"));
}
}
}
}
events.push(GroundEvent::StepAdvanced {
step: RoundStep::Resolve,
});
events
}
/// GR-R08: Stress is already clamped on every application (U2), so
/// End only triggers DARVO, rotates the Lead, and advances the Round.
fn end_round_events(&self) -> Vec<GroundEvent> {
let mut events = Vec::new();
// 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<PlayerId> = self.players.keys().copied().collect();
let next_lead = seats
.iter()
.position(|s| *s == self.lead)
.map_or(self.lead, |i| seats[(i + 1) % seats.len()]);
events.push(GroundEvent::RoundEnded {
round: self.round + 1,
next_lead,
});
events.push(GroundEvent::StepAdvanced {
step: RoundStep::Select,
});
events
}
/// GR-A13 targeting legality, shared by every Action.
fn check_targeting(
&self,
actor: PlayerId,
action: Action,
target: Option<PlayerId>,
problem: Option<u32>,
) -> Result<(), Rejection> {
let bad = |detail: String| Rejection::Game {
code: "bad-target".into(),
detail,
};
if action.requires_player_target() {
let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?;
if target == actor {
return Err(bad(
"GR-A13: SUPPORT and ATTACK target another player".into()
));
}
if !self.players.contains_key(&target) {
return Err(bad(format!("GR-A13: player {target} is not in this game")));
}
} else if target.is_some() {
return Err(bad(format!("GR-A13: {action:?} takes no player target")));
}
if action.requires_problem_target() {
let problem =
problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?;
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<u8, String> {
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<SolutionCard> {
[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<Self, String> {
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<String, String> {
step.args
.get(key)
.and_then(|v| v.as_str().map(str::to_string))
.ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd))
};
let arg_u64 = |key: &str| -> Option<u64> { step.args.get(key).and_then(|v| v.as_u64()) };
let command = match step.cmd.as_str() {
"select_action" => {
let action = Action::parse(&arg_str("action")?)?;
let target = match step.args.get("target") {
Some(_) => match parse_actor(&arg_str("target")?)? {
Actor::Player(id) => Some(id),
Actor::System => return Err("target may not be SYSTEM".into()),
},
None => None,
};
GroundCommand::SelectAction {
action,
target,
problem: arg_u64("problem").map(|p| p as u32),
}
}
"spend_freedom" => GroundCommand::SpendFreedom,
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
"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(),
}
}
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger Round machinery, system-driven (GR-R04/R06/R08): - GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step. Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are not implemented yet; their Actions resolve as no-ops and no scenario claims coverage of them. - SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation formation, flip and break per GR-L01/L03/L04 and Protection cancellation. - GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation, Round advance, per-round flags cleared. - Stress clamps 0-5 on every application, the U2 default, so a mid-round spike that is reduced before End does not trigger DARVO. Two consent-dependent rules are deliberately left out because they need a decision command rather than a default: Bond formation (GR-L02) and the target's flip-or-break choice on Support-through-Rivalry (GR-A05). Both are noted in code and covered by a provisional scenario. Fixes a defect in the T07 scaffold: relations were keyed by a tuple, which JSON cannot use as an object key, so state_hash would have panicked on any state holding a relation. Relation keys are now a Pair newtype serialized as "a-b", with a regression test. setup.patch may now create a final key so scenarios can seed open-ended maps; a typo anywhere earlier in the path is still an error. 8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
/// 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 13, 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));
}
}