clay-borg/games/ground/src/lib.rs
tegwick 4be6e020ea
Some checks failed
ci / check (push) Failing after 3s
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).

Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.

A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.

Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.

Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:

  shipped runtime   23 crates   246,250 lines   target <=250,000  met
  dev toolchain     29 crates   317,021 lines   target <=350,000  met
  own source                      3,408 lines

Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.

Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00

2258 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
//! GameKernel K15K16). Every rule realized here names its GR-id in a doc
//! comment, giving a greppable rule→code→scenario chain.
#[cfg(feature = "scenarios")]
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// GR-O02: per-player state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerState {
/// GR-F01: clamped 05.
pub stress: u8,
/// GR-F03: the Freedom token is READY until spent.
pub freedom_ready: bool,
/// GR-R03: set when Freedom is spent this round, lifting the stress
/// gate for this Select step only. Cleared at round End.
#[serde(default)]
pub freedom_gate_lifted: bool,
/// GR-D01/D02: OFF or the pending/active stage.
pub darvo: DarvoStage,
pub hand: Vec<SolutionCard>,
pub protection: u8,
/// Blame tokens in front of this player (GR-T02), keyed by owner.
pub blame_from: Vec<PlayerId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DarvoStage {
Off,
Deny,
Attack,
Reverse,
}
/// GR-O04: one Problem card's live state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProblemState {
pub suit: Suit,
pub value: u8,
pub face_up: bool,
pub denied: bool,
pub claimed_by: Option<PlayerId>,
/// GR-A11: protected from Deny this round by GROUND—OU.
pub protected_this_round: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Suit {
Clarify,
Repair,
Boundary,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SolutionCard {
pub suit: Suit,
}
/// GR-O05: at most one relation per pair; endpoints ordered low→high.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Relation {
Bond,
Rivalry,
}
/// GR-O05: an unordered player pair, canonically ordered low→high.
///
/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON
/// (GameKernel K7), and JSON object keys must be strings — a tuple key
/// makes `state_hash` fail on any state that holds a relation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Pair(pub PlayerId, pub PlayerId);
impl Pair {
pub fn new(a: PlayerId, b: PlayerId) -> Self {
if a <= b {
Pair(a, b)
} else {
Pair(b, a)
}
}
pub fn contains(&self, player: PlayerId) -> bool {
self.0 == player || self.1 == player
}
}
impl core::fmt::Display for Pair {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}-{}", self.0 .0, self.1 .0)
}
}
impl Serialize for Pair {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Pair {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let (a, b) = raw
.split_once('-')
.ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?;
let parse = |s: &str| {
s.parse::<u8>()
.map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
};
Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
}
}
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
/// collections only (GameKernel K6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroundState {
pub round: u8,
pub lead: PlayerId,
pub players: BTreeMap<PlayerId, PlayerState>,
/// Keyed by (low, high) player pair.
pub relations: BTreeMap<Pair, Relation>,
pub problems: BTreeMap<u32, ProblemState>,
pub solution_deck: Vec<SolutionCard>,
pub solution_discard: Vec<SolutionCard>,
/// Focus placements: sequence owner → target (GR-T03).
pub focus: BTreeMap<PlayerId, PlayerId>,
/// GR-R01: which of the four steps the round is in.
pub step: RoundStep,
/// GR-R02: face-down selections, hidden until Reveal.
pub selections: BTreeMap<PlayerId, Selection>,
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
/// GR-A11/A12: the sub-choice accompanying an OU or ND mode.
pub ground_choices: BTreeMap<PlayerId, GroundChoice>,
/// GR-L02/A05: a Support target's response, keyed by target.
pub support_responses: BTreeMap<PlayerId, SupportResponse>,
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// GR-E02..E04: which scoring mode this game uses.
pub mode: ScoringMode,
/// GR-R09: set once the game has ended and scoring has run.
pub outcome: Option<Outcome>,
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
/// state, keeping `validate` deterministic without holding RNG state.
pub seed: u64,
}
/// GR-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<PlayerId, i32>,
/// GR-E04: each Bond-connected group and its combined score.
pub coalitions: Vec<Coalition>,
/// GR-E02: only meaningful in SHARED GROUND.
pub mastery: Option<i32>,
pub winners: Vec<PlayerId>,
}
/// GR-E04: one Bond-connected group. Unbonded players are solo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coalition {
pub members: Vec<PlayerId>,
pub score: i32,
}
/// GR-D03/D04: what a DARVO stage acts on this round. DENY names a
/// Problem, ATTACK names a player; REVERSE takes its target from the
/// Focus token placed by the ATTACK stage.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DarvoTarget {
pub problem: Option<u32>,
pub player: Option<PlayerId>,
}
/// GR-A10..A12: the three GROUND modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GroundMode {
/// Ground & Restate (GR-A10).
Gr,
/// Observe & Uphold (GR-A11).
Ou,
/// Name & Decide (GR-A12).
Nd,
}
/// GR-A11/A12: the sub-choice a GROUND—OU or GROUND—ND player makes
/// alongside the mode. GROUND—GR takes none.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "choice")]
pub enum GroundChoice {
/// GR-A11: restore one Denied Problem.
RestoreProblem { problem: u32 },
/// GR-A11: cancel one Attack targeting this player this round.
CancelAttack { attacker: PlayerId },
/// GR-A11: protect one face-up Problem from Deny this round.
ProtectProblem { problem: u32 },
/// GR-A12: remove one Blame token from this player.
RemoveBlame { owner: PlayerId },
/// GR-A12: break one relation involving this player.
BreakRelation { with: PlayerId },
/// GR-A12: reject one Reverse targeting this player this round.
RejectReverse,
}
impl GroundChoice {
/// GR-A11/A12: each choice belongs to exactly one mode.
fn mode(self) -> GroundMode {
match self {
GroundChoice::RestoreProblem { .. }
| GroundChoice::CancelAttack { .. }
| GroundChoice::ProtectProblem { .. } => GroundMode::Ou,
GroundChoice::RemoveBlame { .. }
| GroundChoice::BreakRelation { .. }
| GroundChoice::RejectReverse => GroundMode::Nd,
}
}
#[cfg(feature = "scenarios")]
fn parse(raw: &str, arg: Option<u64>) -> Result<Self, String> {
let need = |what: &str| {
arg.ok_or_else(|| format!("GROUND choice {raw:?} needs a {what} argument"))
};
match raw {
"restore_problem" => Ok(GroundChoice::RestoreProblem {
problem: need("problem")? as u32,
}),
"cancel_attack" => Ok(GroundChoice::CancelAttack {
attacker: PlayerId(need("seat")? as u8),
}),
"protect_problem" => Ok(GroundChoice::ProtectProblem {
problem: need("problem")? as u32,
}),
"remove_blame" => Ok(GroundChoice::RemoveBlame {
owner: PlayerId(need("seat")? as u8),
}),
"break_relation" => Ok(GroundChoice::BreakRelation {
with: PlayerId(need("seat")? as u8),
}),
"reject_reverse" => Ok(GroundChoice::RejectReverse),
other => Err(format!("unknown GROUND choice {other:?}")),
}
}
}
/// GR-L02/A05: how a Support target responds, chosen after Reveal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SupportResponse {
/// GR-L02: accept a Bond where no relation exists.
AcceptBond,
/// GR-L02: decline it; the Stress effect still applies.
DeclineBond,
/// GR-A05: turn an existing Rivalry into a Bond.
FlipToBond,
/// GR-A05: break the existing Rivalry.
BreakRivalry,
}
impl SupportResponse {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"accept_bond" => Ok(SupportResponse::AcceptBond),
"decline_bond" => Ok(SupportResponse::DeclineBond),
"flip_to_bond" => Ok(SupportResponse::FlipToBond),
"break_rivalry" => Ok(SupportResponse::BreakRivalry),
other => Err(format!("unknown support response {other:?}")),
}
}
}
impl GroundMode {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"GR" => Ok(GroundMode::Gr),
"OU" => Ok(GroundMode::Ou),
"ND" => Ok(GroundMode::Nd),
other => Err(format!(
"unknown GROUND mode {other:?} (expected GR, OU or ND)"
)),
}
}
}
/// GR-R01: Select → Reveal → Resolve → End.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RoundStep {
Select,
Reveal,
Resolve,
End,
}
/// GR-R02: one player's face-down choice, with its target where the
/// Action requires one (GR-A13).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
pub action: Action,
pub target: Option<PlayerId>,
pub problem: Option<u32>,
}
/// The five Actions (GR-A01..A13). GROUND's mode is chosen at Reveal
/// (GR-R05), not at Select, so it is not part of the selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
Investigate,
Solve,
Support,
Attack,
Ground,
}
impl Action {
#[cfg(feature = "scenarios")]
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"INVESTIGATE" => Ok(Action::Investigate),
"SOLVE" => Ok(Action::Solve),
"SUPPORT" => Ok(Action::Support),
"ATTACK" => Ok(Action::Attack),
"GROUND" => Ok(Action::Ground),
other => Err(format!("unknown action {other:?}")),
}
}
/// GR-R03: the stress gate admits only ATTACK and GROUND.
fn allowed_under_stress_gate(self) -> bool {
matches!(self, Action::Attack | Action::Ground)
}
/// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and
/// SOLVE target a Problem; GROUND targets neither at Select.
fn requires_player_target(self) -> bool {
matches!(self, Action::Support | Action::Attack)
}
fn requires_problem_target(self) -> bool {
matches!(self, Action::Investigate | Action::Solve)
}
}
/// Commands accepted by the GROUND aggregate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroundCommand {
/// GR-R02: choose an Action face down.
SelectAction {
action: Action,
target: Option<PlayerId>,
problem: Option<u32>,
},
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
SpendFreedom,
/// GR-R05: a player who revealed GROUND chooses its mode after
/// seeing all revealed Actions.
ChooseGroundMode {
mode: GroundMode,
choice: Option<GroundChoice>,
},
/// GR-L02/A05: respond to a Support aimed at this player.
RespondToSupport { response: SupportResponse },
/// GR-D03/D04: name the mandatory target of this round's stage.
ChooseDarvoTarget { target: DarvoTarget },
/// GR-R04: reveal all selections simultaneously. System-driven.
Reveal,
/// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
/// first. System-driven.
Resolve,
/// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the
/// Round marker. System-driven.
EndRound,
}
/// Events the aggregate emits. `fold` is total over these (K1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum GroundEvent {
ActionSelected {
player: PlayerId,
selection: Selection,
},
FreedomSpent {
player: PlayerId,
},
/// GR-R04.
Revealed,
/// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial.
StressSet {
player: PlayerId,
stress: u8,
},
/// GR-F04.
FreedomReadied {
player: PlayerId,
},
/// GR-L02/L03: endpoints ordered low→high (GR-O05).
RelationFormed {
pair: Pair,
relation: Relation,
},
/// GR-L04.
RelationBroken {
pair: Pair,
},
/// GR-A09: Protection absorbed an Attack.
AttackCancelled {
attacker: PlayerId,
target: PlayerId,
},
/// GR-R05/A11/A12.
GroundModeChosen {
player: PlayerId,
mode: GroundMode,
choice: Option<GroundChoice>,
},
/// GR-L02/A05.
SupportAnswered {
player: PlayerId,
response: SupportResponse,
},
/// GR-A11: a Denied Problem was restored face up.
ProblemRestored {
problem: u32,
},
/// GR-A11: a face-up Problem is protected from Deny this round.
ProblemProtected {
problem: u32,
},
/// GR-A12/T02: a Blame token was removed and returned to its owner.
BlameRemoved {
player: PlayerId,
owner: PlayerId,
},
/// GR-A01: a hidden Problem was turned face up.
ProblemRevealed {
problem: u32,
},
/// GR-A01: one Solution drawn from the deck.
SolutionDrawn {
player: PlayerId,
card: SolutionCard,
},
/// GR-A02: a matching Solution was spent to claim a Problem.
SolutionDiscarded {
player: PlayerId,
card: SolutionCard,
},
/// GR-A02.
ProblemClaimed {
problem: u32,
by: PlayerId,
},
/// GR-A01 under the U4 default: the discard was reshuffled into the
/// deck. The resulting order travels in the event, so `fold` stays
/// deterministic without replaying the RNG.
DeckReshuffled {
order: Vec<SolutionCard>,
},
/// GR-D01: Stress 5 at End with the marker OFF.
DarvoTriggered {
player: PlayerId,
},
/// GR-D03/D04.
DarvoTargetChosen {
player: PlayerId,
target: DarvoTarget,
},
/// GR-D03: a Problem was turned face down and Denied.
ProblemDenied {
problem: u32,
},
/// GR-D04/T03: the sequence owner's Focus token was placed.
FocusPlaced {
owner: PlayerId,
target: PlayerId,
},
/// GR-D05/T03: Focus flipped to Blame in front of the target.
FocusFlippedToBlame {
owner: PlayerId,
target: PlayerId,
},
/// GR-D05/T01.
ProtectionGained {
player: PlayerId,
},
/// GR-D02: the sequence moved to its next stage.
DarvoAdvanced {
player: PlayerId,
stage: DarvoStage,
},
/// GR-D05/D06/D07: the sequence ended and the marker is OFF again.
DarvoEnded {
player: PlayerId,
},
/// GR-R09/E01..E04: the game ended and scoring ran.
GameEnded {
outcome: Outcome,
},
/// GR-R01: the round advanced to `step`.
StepAdvanced {
step: RoundStep,
},
/// GR-R08: Lead rotated and the Round marker advanced.
RoundEnded {
round: u8,
next_lead: PlayerId,
},
}
impl GroundState {
/// GR-R03: a player at Stress 45 is gated unless Freedom is spent.
/// Spending flips the token to SPENT, so the gate returns next round.
fn stress_gated(&self, player: &PlayerState) -> bool {
let _ = self;
player.stress >= 4 && !player.freedom_gate_lifted
}
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
self.relations.get(&Pair::new(a, b)).copied()
}
/// GR-L01: two relation slots per player.
fn has_free_slot(&self, player: PlayerId) -> bool {
let used = self
.relations
.keys()
.filter(|pair| pair.contains(player))
.count();
used < 2
}
/// GR-R07: resolution order starts at the Lead and continues
/// clockwise (ascending seat, wrapping).
fn seat_order(&self) -> Vec<PlayerId> {
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
seats[start..]
.iter()
.chain(&seats[..start])
.copied()
.collect()
}
/// GR-F01 with the U2 default: clamp on every application, so no
/// intermediate value escapes 05.
fn stress_after(&self, player: PlayerId, delta: i16) -> u8 {
let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress));
current.saturating_add(delta).clamp(0, 5) as u8
}
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
self.players.get(&id).ok_or(Rejection::Game {
code: "no-such-seat".into(),
detail: format!("player {id} is not in this game"),
})
}
}
impl Aggregate for GroundState {
type Command = GroundCommand;
type Event = GroundEvent;
fn validate(
&self,
actor: Actor,
command: &Self::Command,
) -> Result<Vec<Self::Event>, Rejection> {
// GR-R04/R06/R08 are runtime-driven, not player-issued.
match command {
GroundCommand::Reveal => {
if self.step != RoundStep::Select || actor != Actor::System {
return Err(Rejection::NotAllowedNow);
}
if self.selections.len() != self.players.len() {
return Err(Rejection::Game {
code: "select-incomplete".into(),
detail: "GR-R04: every player must select before Reveal".into(),
});
}
return Ok(vec![
GroundEvent::Revealed,
GroundEvent::StepAdvanced {
step: RoundStep::Reveal,
},
]);
}
GroundCommand::Resolve => {
if self.step != RoundStep::Reveal || actor != Actor::System {
return Err(Rejection::NotAllowedNow);
}
// GR-R05: modes are chosen before resolution begins.
let pending: Vec<PlayerId> = self
.selections
.iter()
.filter(|(seat, sel)| {
sel.action == Action::Ground && !self.ground_modes.contains_key(seat)
})
.map(|(seat, _)| *seat)
.collect();
if !pending.is_empty() {
return Err(Rejection::Game {
code: "ground-mode-pending".into(),
detail: format!("GR-R05: no GROUND mode chosen for {pending:?}"),
});
}
return Ok(self.resolution_events());
}
GroundCommand::EndRound => {
if self.step != RoundStep::Resolve || actor != Actor::System {
return Err(Rejection::NotAllowedNow);
}
return Ok(self.end_round_events());
}
_ => {}
}
let Actor::Player(id) = actor else {
return Err(Rejection::NotAllowedNow);
};
let player = self.player(id)?;
match command {
// GR-R02: one face-down choice per player, during Select only.
GroundCommand::SelectAction {
action,
target,
problem,
} => {
if self.step != RoundStep::Select {
return Err(Rejection::NotAllowedNow);
}
if self.selections.contains_key(&id) {
return Err(Rejection::DuplicateCommand);
}
if self.stress_gated(player) && !action.allowed_under_stress_gate() {
return Err(Rejection::Game {
code: "stress-gate".into(),
detail: "GR-R03: at Stress 45 only ATTACK or GROUND may be selected"
.into(),
});
}
self.check_targeting(id, *action, *target, *problem)?;
Ok(vec![GroundEvent::ActionSelected {
player: id,
selection: Selection {
action: *action,
target: *target,
problem: *problem,
},
}])
}
// GR-R03: spendable during Select, before Reveal, once.
GroundCommand::SpendFreedom => {
if self.step != RoundStep::Select {
return Err(Rejection::NotAllowedNow);
}
if !player.freedom_ready {
return Err(Rejection::Game {
code: "freedom-spent".into(),
detail: "GR-F03: the Freedom token is already SPENT".into(),
});
}
Ok(vec![GroundEvent::FreedomSpent { player: id }])
}
// GR-R05: only after Reveal, only for a player who revealed
// GROUND, once.
GroundCommand::ChooseGroundMode { mode, choice } => {
if self.step != RoundStep::Reveal {
return Err(Rejection::NotAllowedNow);
}
let revealed_ground = self
.selections
.get(&id)
.is_some_and(|s| s.action == Action::Ground);
if !revealed_ground {
return Err(Rejection::Game {
code: "no-ground-revealed".into(),
detail: "GR-R05: only a player who revealed GROUND chooses a mode".into(),
});
}
if self.ground_modes.contains_key(&id) {
return Err(Rejection::DuplicateCommand);
}
let _ = player;
// GR-A10..A12: GR takes no sub-choice; OU and ND each
// require one drawn from their own list.
match (mode, choice) {
(GroundMode::Gr, None) => {}
(GroundMode::Gr, Some(_)) => {
return Err(Rejection::Game {
code: "unexpected-choice".into(),
detail: "GR-A10: GROUND—GR takes no sub-choice".into(),
})
}
(wanted, Some(choice)) if choice.mode() == *wanted => {}
(wanted, _) => {
return Err(Rejection::Game {
code: "bad-choice".into(),
detail: format!("GR-A11/A12: {wanted:?} needs one of its own choices"),
})
}
}
self.check_ground_choice(id, *choice)?;
Ok(vec![GroundEvent::GroundModeChosen {
player: id,
mode: *mode,
choice: *choice,
}])
}
// GR-L02/A05: only the target of a revealed Support answers,
// once, and only with a response its relation admits.
GroundCommand::RespondToSupport { response } => {
if self.step != RoundStep::Reveal {
return Err(Rejection::NotAllowedNow);
}
let supporter = self.selections.iter().find(|(seat, sel)| {
sel.action == Action::Support && sel.target == Some(id) && **seat != id
});
let Some((supporter, _)) = supporter else {
return Err(Rejection::Game {
code: "no-support-received".into(),
detail: "GR-L02: no revealed Support targets this player".into(),
});
};
if self.support_responses.contains_key(&id) {
return Err(Rejection::DuplicateCommand);
}
let admitted = match self.relation_between(*supporter, id) {
// GR-L02: no relation — the target may accept a Bond.
None => matches!(
response,
SupportResponse::AcceptBond | SupportResponse::DeclineBond
),
// GR-A05: through a Rivalry — flip it or break it.
Some(Relation::Rivalry) => matches!(
response,
SupportResponse::FlipToBond | SupportResponse::BreakRivalry
),
// GR-A04: through a Bond — nothing to answer.
Some(Relation::Bond) => false,
};
if !admitted {
return Err(Rejection::Game {
code: "bad-response".into(),
detail: format!("GR-L02/A05: {response:?} is not available here"),
});
}
Ok(vec![GroundEvent::SupportAnswered {
player: id,
response: *response,
}])
}
// GR-D03/D04: only a player with a live sequence, after
// Reveal, once per round.
GroundCommand::ChooseDarvoTarget { target } => {
if self.step != RoundStep::Reveal {
return Err(Rejection::NotAllowedNow);
}
if player.darvo == DarvoStage::Off {
return Err(Rejection::Game {
code: "no-darvo-sequence".into(),
detail: "GR-D02: this player has no live DARVO sequence".into(),
});
}
if self.darvo_targets.contains_key(&id) {
return Err(Rejection::DuplicateCommand);
}
match player.darvo {
// GR-D03: a face-up, unsolved, unprotected Problem.
DarvoStage::Deny => {
let problem = target.problem.ok_or(Rejection::Game {
code: "bad-darvo-target".into(),
detail: "GR-D03: DENY names a Problem".into(),
})?;
let eligible = self.problems.get(&problem).is_some_and(|p| {
p.face_up
&& !p.denied
&& p.claimed_by.is_none()
&& !p.protected_this_round
});
if !eligible {
return Err(Rejection::Game {
code: "bad-darvo-target".into(),
detail: format!(
"GR-D03: Problem {problem} is not face-up, unsolved and unprotected"
),
});
}
}
// GR-D04: an extra Attack against another player.
DarvoStage::Attack => {
let other = target.player.ok_or(Rejection::Game {
code: "bad-darvo-target".into(),
detail: "GR-D04: ATTACK names a player".into(),
})?;
if other == id || !self.players.contains_key(&other) {
return Err(Rejection::Game {
code: "bad-darvo-target".into(),
detail: "GR-D04: ATTACK targets another player".into(),
});
}
}
// GR-D05: REVERSE uses the placed Focus token.
DarvoStage::Reverse | DarvoStage::Off => {}
}
Ok(vec![GroundEvent::DarvoTargetChosen {
player: id,
target: *target,
}])
}
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
unreachable!("system commands are handled above")
}
}
}
fn fold(&mut self, event: &Self::Event) {
match event {
GroundEvent::ActionSelected { player, selection } => {
self.selections.insert(*player, *selection);
}
GroundEvent::FreedomSpent { player } => {
if let Some(state) = self.players.get_mut(player) {
state.freedom_ready = false;
state.freedom_gate_lifted = true;
}
}
GroundEvent::Revealed => {}
GroundEvent::StressSet { player, stress } => {
if let Some(state) = self.players.get_mut(player) {
state.stress = *stress;
}
}
GroundEvent::FreedomReadied { player } => {
if let Some(state) = self.players.get_mut(player) {
state.freedom_ready = true;
}
}
GroundEvent::RelationFormed { pair, relation } => {
self.relations.insert(*pair, *relation);
}
GroundEvent::RelationBroken { pair } => {
self.relations.remove(pair);
}
GroundEvent::AttackCancelled { target, .. } => {
if let Some(state) = self.players.get_mut(target) {
state.protection = state.protection.saturating_sub(1);
}
}
GroundEvent::GroundModeChosen {
player,
mode,
choice,
} => {
self.ground_modes.insert(*player, *mode);
if let Some(choice) = choice {
self.ground_choices.insert(*player, *choice);
}
}
GroundEvent::SupportAnswered { player, response } => {
self.support_responses.insert(*player, *response);
}
GroundEvent::ProblemRestored { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
state.denied = false;
state.face_up = true;
}
}
GroundEvent::ProblemProtected { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
state.protected_this_round = true;
}
}
GroundEvent::BlameRemoved { player, owner } => {
if let Some(state) = self.players.get_mut(player) {
if let Some(pos) = state.blame_from.iter().position(|o| o == owner) {
state.blame_from.remove(pos);
}
}
}
GroundEvent::ProblemRevealed { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
state.face_up = true;
}
}
GroundEvent::SolutionDrawn { player, card } => {
// The deck draws from its end, matching the GR-S02 deal.
self.solution_deck.pop();
if let Some(state) = self.players.get_mut(player) {
state.hand.push(*card);
}
}
GroundEvent::SolutionDiscarded { player, card } => {
if let Some(state) = self.players.get_mut(player) {
if let Some(pos) = state.hand.iter().position(|c| c == card) {
state.hand.remove(pos);
}
}
self.solution_discard.push(*card);
}
GroundEvent::ProblemClaimed { problem, by } => {
if let Some(state) = self.problems.get_mut(problem) {
state.claimed_by = Some(*by);
}
}
GroundEvent::DeckReshuffled { order } => {
self.solution_deck = order.clone();
self.solution_discard.clear();
}
GroundEvent::DarvoTriggered { player } => {
if let Some(state) = self.players.get_mut(player) {
state.darvo = DarvoStage::Deny;
}
}
GroundEvent::DarvoTargetChosen { player, target } => {
self.darvo_targets.insert(*player, *target);
}
GroundEvent::ProblemDenied { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
state.denied = true;
state.face_up = false;
}
}
GroundEvent::FocusPlaced { owner, target } => {
self.focus.insert(*owner, *target);
}
GroundEvent::FocusFlippedToBlame { owner, target } => {
self.focus.remove(owner);
if let Some(state) = self.players.get_mut(target) {
state.blame_from.push(*owner);
}
}
GroundEvent::ProtectionGained { player } => {
if let Some(state) = self.players.get_mut(player) {
state.protection = state.protection.saturating_add(1);
}
}
GroundEvent::DarvoAdvanced { player, stage } => {
if let Some(state) = self.players.get_mut(player) {
state.darvo = *stage;
}
}
GroundEvent::DarvoEnded { player } => {
if let Some(state) = self.players.get_mut(player) {
state.darvo = DarvoStage::Off;
}
// GR-D06: an unresolved Focus token comes back.
self.focus.remove(player);
}
GroundEvent::GameEnded { outcome } => {
self.outcome = Some(outcome.clone());
self.step = RoundStep::End;
}
GroundEvent::StepAdvanced { step } => {
self.step = *step;
}
GroundEvent::RoundEnded { round, next_lead } => {
self.round = *round;
self.lead = *next_lead;
self.selections.clear();
self.ground_modes.clear();
self.ground_choices.clear();
self.support_responses.clear();
self.darvo_targets.clear();
for state in self.players.values_mut() {
// GR-R03: the gate lift lasts one Select step only.
state.freedom_gate_lifted = false;
}
for problem in self.problems.values_mut() {
// GR-A11: OU protection lasts one round.
problem.protected_this_round = false;
}
}
}
}
}
impl GroundState {
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
///
/// 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<GroundEvent> {
let mut events = Vec::new();
// A working copy so slot counts and Stress reflect earlier
// effects within the same resolution, per GR-R07 ordering.
let mut work = self.clone();
// Relations as they stood before this round's Support step
// (GR-L05): a Bond formed now is not "pre-existing".
let pre_existing = self.relations.clone();
// GR-A11: Attacks cancelled by a GROUND—OU choice this round.
let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> =
std::collections::BTreeSet::new();
// Step 1 — GROUND (GR-A10..A12).
for actor in self.seat_order() {
if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) {
continue;
}
match self.ground_modes.get(&actor) {
// GR-A10: Ground & Restate — self 2 Stress, Freedom READY.
Some(GroundMode::Gr) => {
let stress = work.stress_after(actor, -2);
events.push(GroundEvent::StressSet {
player: actor,
stress,
});
work.fold(events.last().expect("just pushed"));
if !work.players[&actor].freedom_ready {
events.push(GroundEvent::FreedomReadied { player: actor });
work.fold(events.last().expect("just pushed"));
}
}
// GR-A11: Observe & Uphold.
Some(GroundMode::Ou) => match work.ground_choices.get(&actor).copied() {
Some(GroundChoice::RestoreProblem { problem }) => {
events.push(GroundEvent::ProblemRestored { problem });
work.fold(events.last().expect("just pushed"));
}
Some(GroundChoice::ProtectProblem { problem }) => {
events.push(GroundEvent::ProblemProtected { problem });
work.fold(events.last().expect("just pushed"));
}
// Consumed in the Attack step below.
Some(GroundChoice::CancelAttack { attacker }) => {
ou_cancels.insert((attacker, actor));
}
_ => {}
},
// GR-A12: Name & Decide.
Some(GroundMode::Nd) => match work.ground_choices.get(&actor).copied() {
Some(GroundChoice::RemoveBlame { owner }) => {
events.push(GroundEvent::BlameRemoved {
player: actor,
owner,
});
work.fold(events.last().expect("just pushed"));
}
Some(GroundChoice::BreakRelation { with }) => {
events.push(GroundEvent::RelationBroken {
pair: Pair::new(actor, with),
});
work.fold(events.last().expect("just pushed"));
}
// GR-D05: consumed by the Reverse stage.
_ => {}
},
None => {}
}
}
// Step 2 — Support (GR-A03/A04/A05).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Support {
continue;
}
let Some(target) = selection.target else {
continue;
};
match pre_existing.get(&Pair::new(actor, target)).copied() {
// GR-A04: Support through an existing Bond.
Some(Relation::Bond) => {
let stress = work.stress_after(target, -2);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
// GR-F04: a Bond Support readies the target's token.
if !work.players[&target].freedom_ready {
events.push(GroundEvent::FreedomReadied { player: target });
work.fold(events.last().expect("just pushed"));
}
}
// GR-A05: Support through a Rivalry — 1 Stress, then
// the target flips it to a Bond or breaks it.
Some(Relation::Rivalry) => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
let pair = Pair::new(actor, target);
match work.support_responses.get(&target).copied() {
Some(SupportResponse::FlipToBond) => {
events.push(GroundEvent::RelationFormed {
pair,
relation: Relation::Bond,
});
work.fold(events.last().expect("just pushed"));
}
Some(SupportResponse::BreakRivalry) => {
events.push(GroundEvent::RelationBroken { pair });
work.fold(events.last().expect("just pushed"));
}
// No answer: the Rivalry stands.
_ => {}
}
}
// GR-A03/L02: no relation — 1 Stress, and a Bond forms
// only if the target accepts and both have a free slot.
None => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
let accepted = work.support_responses.get(&target).copied()
== Some(SupportResponse::AcceptBond);
if accepted && work.has_free_slot(actor) && work.has_free_slot(target) {
events.push(GroundEvent::RelationFormed {
pair: Pair::new(actor, target),
relation: Relation::Bond,
});
work.fold(events.last().expect("just pushed"));
}
}
}
}
// Step 3 — active DARVO stages (GR-D02..D07).
for owner in self.seat_order() {
let stage = work.players[&owner].darvo;
if stage == DarvoStage::Off {
continue;
}
// GR-D06 + GR-A04: a Support through a Bond that existed
// before this round's Support step cancels the current stage
// and ends the sequence (GR-L05).
let bond_support = self.selections.iter().any(|(seat, sel)| {
sel.action == Action::Support
&& sel.target == Some(owner)
&& pre_existing.get(&Pair::new(*seat, owner)) == Some(&Relation::Bond)
});
if bond_support {
events.push(GroundEvent::DarvoEnded { player: owner });
work.fold(events.last().expect("just pushed"));
continue;
}
match stage {
// GR-D03: turn one eligible Problem face down and Deny
// it. Under the U3 default, no legal target is a no-op
// and the sequence still advances.
DarvoStage::Deny => {
if let Some(problem) = work.darvo_targets.get(&owner).and_then(|t| t.problem) {
let eligible = work.problems.get(&problem).is_some_and(|p| {
p.face_up
&& !p.denied
&& p.claimed_by.is_none()
&& !p.protected_this_round
});
if eligible {
events.push(GroundEvent::ProblemDenied { problem });
work.fold(events.last().expect("just pushed"));
}
}
}
// GR-D04: one extra Attack under the normal relation
// rules, then place the Focus token beside the target —
// even if the Attack was cancelled.
DarvoStage::Attack => {
if let Some(target) = work.darvo_targets.get(&owner).and_then(|t| t.player) {
work.resolve_attack(owner, target, &ou_cancels, &mut events);
events.push(GroundEvent::FocusPlaced { owner, target });
work.fold(events.last().expect("just pushed"));
}
}
// GR-D05: targets the Focus holder.
DarvoStage::Reverse => {
if let Some(target) = work.focus.get(&owner).copied() {
// GR-A12: the target's GROUND—ND may reject it.
let rejected =
work.ground_choices.get(&target) == Some(&GroundChoice::RejectReverse);
if !rejected {
events.push(GroundEvent::FocusFlippedToBlame { owner, target });
work.fold(events.last().expect("just pushed"));
let stress = work.stress_after(target, 1);
events.push(GroundEvent::StressSet {
player: target,
stress,
});
work.fold(events.last().expect("just pushed"));
events.push(GroundEvent::ProtectionGained { player: owner });
work.fold(events.last().expect("just pushed"));
}
// U5: rejected or not, the owner still takes 2
// and the sequence ends.
let stress = work.stress_after(owner, -2);
events.push(GroundEvent::StressSet {
player: owner,
stress,
});
work.fold(events.last().expect("just pushed"));
}
}
DarvoStage::Off => {}
}
// GR-A10 + GR-D06: GROUND—GR ends the sequence after the
// current stage resolves. GR-D05: REVERSE ends it anyway.
let ground_gr = work.ground_modes.get(&owner) == Some(&GroundMode::Gr);
if ground_gr || stage == DarvoStage::Reverse {
events.push(GroundEvent::DarvoEnded { player: owner });
work.fold(events.last().expect("just pushed"));
} else {
// GR-D02: one stage per consecutive round.
let next = match stage {
DarvoStage::Deny => DarvoStage::Attack,
_ => DarvoStage::Reverse,
};
events.push(GroundEvent::DarvoAdvanced {
player: owner,
stage: next,
});
work.fold(events.last().expect("just pushed"));
}
}
// Step 5 — Attack (GR-A06..A09).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Attack {
continue;
}
let Some(target) = selection.target else {
continue;
};
work.resolve_attack(actor, target, &ou_cancels, &mut events);
}
// Step 4 — INVESTIGATE (GR-A01).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Investigate {
continue;
}
// Reveal the chosen Problem if it is still hidden and not
// Denied; otherwise the draw happens on its own.
if let Some(problem) = selection.problem {
let eligible = work
.problems
.get(&problem)
.is_some_and(|p| !p.face_up && !p.denied);
if eligible {
events.push(GroundEvent::ProblemRevealed { problem });
work.fold(events.last().expect("just pushed"));
}
}
work.draw_solution(actor, &mut events);
}
// Step 6 — SOLVE (GR-A02).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Solve {
continue;
}
let Some(problem) = selection.problem else {
continue;
};
let Some(target) = work.problems.get(&problem) else {
continue;
};
// GR-A02: an earlier resolver this round already claimed it,
// so no Solution is spent and nothing happens.
if target.claimed_by.is_some() || target.denied || !target.face_up {
continue;
}
let required = target.suit;
let Some(card) = work.players[&actor]
.hand
.iter()
.find(|c| c.suit == required)
.copied()
else {
continue;
};
events.push(GroundEvent::SolutionDiscarded {
player: actor,
card,
});
work.fold(events.last().expect("just pushed"));
events.push(GroundEvent::ProblemClaimed { problem, by: actor });
work.fold(events.last().expect("just pushed"));
}
events.push(GroundEvent::StepAdvanced {
step: RoundStep::Resolve,
});
events
}
/// GR-A06..A09: one Attack under the normal relation rules. Shared
/// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's
/// extra Attack (GR-D04).
fn resolve_attack(
&mut self,
attacker: PlayerId,
target: PlayerId,
ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>,
events: &mut Vec<GroundEvent>,
) {
// 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<GroundEvent>) {
if self.solution_deck.is_empty() {
if self.solution_discard.is_empty() {
return;
}
let mut order = self.solution_discard.clone();
// Derived from the game seed and round, so the reshuffle is
// a pure function of state (GameKernel K5).
let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round)));
rng.shuffle(&mut order);
events.push(GroundEvent::DeckReshuffled { order });
self.fold(events.last().expect("just pushed"));
}
if let Some(card) = self.solution_deck.last().copied() {
events.push(GroundEvent::SolutionDrawn { player, card });
self.fold(events.last().expect("just pushed"));
}
}
/// GR-R08: Stress is already clamped on every application (U2), so
/// End only triggers DARVO, rotates the Lead, and advances the Round.
fn end_round_events(&self) -> Vec<GroundEvent> {
let mut events = Vec::new();
// 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()]);
// 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<PlayerId, i32> = self
.players
.keys()
.map(|seat| {
let claimed: i32 = self
.problems
.values()
.filter(|p| p.claimed_by == Some(*seat))
.map(|p| i32::from(p.value))
.sum();
let blame = self.players[seat].blame_from.len() as i32;
(*seat, claimed - blame)
})
.collect();
let coalitions = self.coalitions(&personal);
let (mastery, winners) = match self.mode {
// GR-E02: one shared score; no individual winner.
ScoringMode::SharedGround => {
let blame: i32 = self
.players
.values()
.map(|p| p.blame_from.len() as i32)
.sum();
let denied = self.problems.values().filter(|p| p.denied).count() as i32;
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<PlayerId> = best
.into_iter()
.flat_map(|i| coalitions[i].members.clone())
.collect();
winners.sort();
winners
} else {
Vec::new()
};
(None, winners)
}
};
Outcome {
total,
threshold,
group_success,
personal,
coalitions,
mastery,
winners,
}
}
/// Every candidate tied on the ranking key, so a tie is a shared
/// result rather than an arbitrary pick (GR-E03/E04).
fn best<T: Copy, K: Ord>(&self, candidates: Vec<T>, key: impl Fn(T) -> K) -> Vec<T> {
let Some(top) = candidates.iter().map(|c| key(*c)).max() else {
return Vec::new();
};
candidates.into_iter().filter(|c| key(*c) == top).collect()
}
fn bond_count(&self, seat: PlayerId) -> i32 {
self.relations
.iter()
.filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat))
.count() as i32
}
/// GR-E04: connected components over Bonds only; Rivalries do not
/// connect, and an unbonded player is a coalition of one.
fn coalitions(&self, personal: &BTreeMap<PlayerId, i32>) -> Vec<Coalition> {
let mut remaining: Vec<PlayerId> = self.players.keys().copied().collect();
let mut out = Vec::new();
while let Some(seed) = remaining.first().copied() {
let mut members = vec![seed];
let mut frontier = vec![seed];
remaining.retain(|p| *p != seed);
while let Some(current) = frontier.pop() {
let neighbours: Vec<PlayerId> = self
.relations
.iter()
.filter(|(_, rel)| **rel == Relation::Bond)
.filter_map(|(pair, _)| {
if pair.0 == current {
Some(pair.1)
} else if pair.1 == current {
Some(pair.0)
} else {
None
}
})
.filter(|n| remaining.contains(n))
.collect();
for n in neighbours {
remaining.retain(|p| *p != n);
members.push(n);
frontier.push(n);
}
}
members.sort();
let score = members.iter().map(|m| personal[m]).sum();
out.push(Coalition { members, score });
}
out
}
/// GR-A11/A12: the sub-choice must name something that exists and
/// is in a state the choice can act on.
fn check_ground_choice(
&self,
actor: PlayerId,
choice: Option<GroundChoice>,
) -> Result<(), Rejection> {
let bad = |detail: String| Rejection::Game {
code: "bad-choice".into(),
detail,
};
let problem = |id: u32| {
self.problems
.get(&id)
.ok_or_else(|| bad(format!("no Problem {id}")))
};
match choice {
None => Ok(()),
// GR-A11: only a Denied Problem can be restored.
Some(GroundChoice::RestoreProblem { problem: id }) => {
if !problem(id)?.denied {
return Err(bad(format!("GR-A11: Problem {id} is not Denied")));
}
Ok(())
}
// GR-A11: only a face-up Problem can be protected.
Some(GroundChoice::ProtectProblem { problem: id }) => {
if !problem(id)?.face_up || problem(id)?.denied {
return Err(bad(format!("GR-A11: Problem {id} is not face up")));
}
Ok(())
}
// GR-A11: the cancelled Attack must actually target us.
Some(GroundChoice::CancelAttack { attacker }) => {
let aimed_here = self
.selections
.get(&attacker)
.is_some_and(|s| s.action == Action::Attack && s.target == Some(actor));
if !aimed_here {
return Err(bad(format!(
"GR-A11: {attacker} is not attacking this player"
)));
}
Ok(())
}
// GR-A12/T02: the Blame token must be in front of us.
Some(GroundChoice::RemoveBlame { owner }) => {
let held = self
.players
.get(&actor)
.is_some_and(|p| p.blame_from.contains(&owner));
if !held {
return Err(bad(format!("GR-T02: no Blame token from {owner} here")));
}
Ok(())
}
// GR-A12: the relation must exist and involve us.
Some(GroundChoice::BreakRelation { with }) => {
if self.relation_between(actor, with).is_none() {
return Err(bad(format!("GR-A12: no relation with {with}")));
}
Ok(())
}
Some(GroundChoice::RejectReverse) => Ok(()),
}
}
/// GR-A13 targeting legality, shared by every Action.
fn check_targeting(
&self,
actor: PlayerId,
action: Action,
target: Option<PlayerId>,
problem: Option<u32>,
) -> Result<(), Rejection> {
let bad = |detail: String| Rejection::Game {
code: "bad-target".into(),
detail,
};
if action.requires_player_target() {
let target = target.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a target")))?;
if target == actor {
return Err(bad(
"GR-A13: SUPPORT and ATTACK target another player".into()
));
}
if !self.players.contains_key(&target) {
return Err(bad(format!("GR-A13: player {target} is not in this game")));
}
} else if target.is_some() {
return Err(bad(format!("GR-A13: {action:?} takes no player target")));
}
if action.requires_problem_target() {
let problem =
problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?;
let target = self
.problems
.get(&problem)
.ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?;
match action {
// GR-A13: INVESTIGATE targets a hidden Problem.
Action::Investigate if target.face_up => {
return Err(bad(format!("GR-A13: Problem {problem} is already face up")));
}
// GR-A13: SOLVE targets a face-up, non-Denied Problem.
Action::Solve if !target.face_up || target.denied => {
return Err(bad(format!(
"GR-A13: Problem {problem} is not a face-up, non-Denied Problem"
)));
}
_ => {}
}
} 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<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.
#[cfg(feature = "scenarios")]
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()
}
#[cfg(feature = "scenarios")]
impl ScenarioGame for GroundState {
/// GR-S01..S04. The `standard-Np` presets differ only in seat count;
/// Problem content is scenario data, so the preset uses the canonical
/// fixture below (suit cycling by priority) until scenario decks are
/// modelled.
fn setup(setup: &Setup, seed: u64) -> Result<Self, String> {
let seats = setup.players;
let 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<String, String> {
step.args
.get(key)
.and_then(|v| v.as_str().map(str::to_string))
.ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd))
};
let arg_u64 = |key: &str| -> Option<u64> { step.args.get(key).and_then(|v| v.as_u64()) };
let command = match step.cmd.as_str() {
"select_action" => {
let action = Action::parse(&arg_str("action")?)?;
let target = match step.args.get("target") {
Some(_) => match parse_actor(&arg_str("target")?)? {
Actor::Player(id) => Some(id),
Actor::System => return Err("target may not be SYSTEM".into()),
},
None => None,
};
GroundCommand::SelectAction {
action,
target,
problem: arg_u64("problem").map(|p| p as u32),
}
}
"spend_freedom" => GroundCommand::SpendFreedom,
"choose_ground_mode" => GroundCommand::ChooseGroundMode {
mode: GroundMode::parse(&arg_str("mode")?)?,
choice: match step.args.get("choice") {
Some(_) => Some(GroundChoice::parse(
&arg_str("choice")?,
arg_u64("problem").or_else(|| arg_u64("seat")),
)?),
None => None,
},
},
"choose_darvo_target" => GroundCommand::ChooseDarvoTarget {
target: DarvoTarget {
problem: arg_u64("problem").map(|p| p as u32),
player: match step.args.get("target") {
Some(_) => match parse_actor(&arg_str("target")?)? {
Actor::Player(seat) => Some(seat),
Actor::System => return Err("target may not be SYSTEM".into()),
},
None => None,
},
},
},
"respond_to_support" => GroundCommand::RespondToSupport {
response: SupportResponse::parse(&arg_str("response")?)?,
},
"reveal" => GroundCommand::Reveal,
"resolve" => GroundCommand::Resolve,
"end_round" => GroundCommand::EndRound,
other => return Err(format!("unknown command {other:?}")),
};
Ok((actor, command))
}
fn round(&self) -> u8 {
self.round
}
}
#[cfg(test)]
mod tests {
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 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));
}
}
#[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<GroundEvent>) -> usize {
let mut n = 0;
let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| {
if let Ok(produced) = state.validate(actor, cmd) {
for e in &produced {
state.fold(e);
log.push(e.clone());
n += 1;
}
}
};
for (seat, action, target) in [
(0u8, Action::Attack, Some(PlayerId(1))),
(2, Action::Support, Some(PlayerId(1))),
(1, Action::Ground, None),
] {
run(
state,
Actor::Player(PlayerId(seat)),
&GroundCommand::SelectAction {
action,
target,
problem: None,
},
);
}
run(state, Actor::System, &GroundCommand::Reveal);
run(
state,
Actor::Player(PlayerId(1)),
&GroundCommand::ChooseGroundMode {
mode: GroundMode::Gr,
choice: None,
},
);
run(state, Actor::System, &GroundCommand::Resolve);
run(state, Actor::System, &GroundCommand::EndRound);
n
}
/// 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");
}
}
}