2026-07-31 01:57:13 +02:00
|
|
|
|
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
|
|
|
|
|
//! comment, giving a greppable rule→code→scenario chain.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
|
|
|
|
|
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
2026-07-31 01:57:13 +02:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O02: per-player state.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct PlayerState {
|
|
|
|
|
|
/// GR-F01: clamped 0–5.
|
|
|
|
|
|
pub stress: u8,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-F03: the Freedom token is READY until spent.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
pub freedom_ready: bool,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// 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,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// 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-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<(PlayerId, PlayerId), 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>,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Events the aggregate emits. `fold` is total over these (K1).
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
#[serde(tag = "kind")]
|
|
|
|
|
|
pub enum GroundEvent {
|
|
|
|
|
|
ActionSelected {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
selection: Selection,
|
|
|
|
|
|
},
|
|
|
|
|
|
FreedomSpent {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GroundState {
|
|
|
|
|
|
/// GR-R03: a player at Stress 4–5 is gated unless Freedom is spent.
|
|
|
|
|
|
/// Spending flips the token to SPENT, so the gate returns next round.
|
|
|
|
|
|
fn stress_gated(&self, player: &PlayerState) -> bool {
|
|
|
|
|
|
let _ = self;
|
|
|
|
|
|
player.stress >= 4 && !player.freedom_gate_lifted
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
|
|
|
|
|
self.players.get(&id).ok_or(Rejection::Game {
|
|
|
|
|
|
code: "no-such-seat".into(),
|
|
|
|
|
|
detail: format!("player {id} is not in this game"),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Aggregate for GroundState {
|
|
|
|
|
|
type Command = GroundCommand;
|
|
|
|
|
|
type Event = GroundEvent;
|
|
|
|
|
|
|
|
|
|
|
|
fn validate(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
actor: Actor,
|
|
|
|
|
|
command: &Self::Command,
|
|
|
|
|
|
) -> Result<Vec<Self::Event>, Rejection> {
|
|
|
|
|
|
let Actor::Player(id) = actor else {
|
|
|
|
|
|
return Err(Rejection::NotAllowedNow);
|
|
|
|
|
|
};
|
|
|
|
|
|
let player = self.player(id)?;
|
|
|
|
|
|
|
|
|
|
|
|
match command {
|
|
|
|
|
|
// GR-R02: one face-down choice per player, during Select only.
|
|
|
|
|
|
GroundCommand::SelectAction {
|
|
|
|
|
|
action,
|
|
|
|
|
|
target,
|
|
|
|
|
|
problem,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
if self.step != RoundStep::Select {
|
|
|
|
|
|
return Err(Rejection::NotAllowedNow);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.selections.contains_key(&id) {
|
|
|
|
|
|
return Err(Rejection::DuplicateCommand);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.stress_gated(player) && !action.allowed_under_stress_gate() {
|
|
|
|
|
|
return Err(Rejection::Game {
|
|
|
|
|
|
code: "stress-gate".into(),
|
|
|
|
|
|
detail: "GR-R03: at Stress 4–5 only ATTACK or GROUND may be selected"
|
|
|
|
|
|
.into(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
self.check_targeting(id, *action, *target, *problem)?;
|
|
|
|
|
|
Ok(vec![GroundEvent::ActionSelected {
|
|
|
|
|
|
player: id,
|
|
|
|
|
|
selection: Selection {
|
|
|
|
|
|
action: *action,
|
|
|
|
|
|
target: *target,
|
|
|
|
|
|
problem: *problem,
|
|
|
|
|
|
},
|
|
|
|
|
|
}])
|
|
|
|
|
|
}
|
|
|
|
|
|
// GR-R03: spendable during Select, before Reveal, once.
|
|
|
|
|
|
GroundCommand::SpendFreedom => {
|
|
|
|
|
|
if self.step != RoundStep::Select {
|
|
|
|
|
|
return Err(Rejection::NotAllowedNow);
|
|
|
|
|
|
}
|
|
|
|
|
|
if !player.freedom_ready {
|
|
|
|
|
|
return Err(Rejection::Game {
|
|
|
|
|
|
code: "freedom-spent".into(),
|
|
|
|
|
|
detail: "GR-F03: the Freedom token is already SPENT".into(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(vec![GroundEvent::FreedomSpent { player: id }])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn fold(&mut self, event: &Self::Event) {
|
|
|
|
|
|
match event {
|
|
|
|
|
|
GroundEvent::ActionSelected { player, selection } => {
|
|
|
|
|
|
self.selections.insert(*player, *selection);
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::FreedomSpent { player } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.freedom_ready = false;
|
|
|
|
|
|
state.freedom_gate_lifted = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GroundState {
|
|
|
|
|
|
/// GR-A13 targeting legality, shared by every Action.
|
|
|
|
|
|
fn check_targeting(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
actor: PlayerId,
|
|
|
|
|
|
action: Action,
|
|
|
|
|
|
target: Option<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,
|
|
|
|
|
|
other => return Err(format!("unknown command {other:?}")),
|
|
|
|
|
|
};
|
|
|
|
|
|
Ok((actor, command))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn round(&self) -> u8 {
|
|
|
|
|
|
self.round
|
|
|
|
|
|
}
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
freedom_gate_lifted: false,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
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(),
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
selections: BTreeMap::new(),
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn setup_3p(seed: u64) -> GroundState {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: 3,
|
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
|
patch: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
seed,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
|
|
|
|
|
|
/// and the deck loses exactly what was dealt.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn setup_deals_per_gr_s02_and_s04() {
|
|
|
|
|
|
let state = setup_3p(42);
|
|
|
|
|
|
assert_eq!(state.players.len(), 3);
|
|
|
|
|
|
assert_eq!(state.round, 1);
|
|
|
|
|
|
for player in state.players.values() {
|
|
|
|
|
|
assert_eq!(player.stress, 2);
|
|
|
|
|
|
assert!(player.freedom_ready);
|
|
|
|
|
|
assert_eq!(player.darvo, DarvoStage::Off);
|
|
|
|
|
|
assert_eq!(player.hand.len(), 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(state.solution_deck.len(), 24 - 6);
|
|
|
|
|
|
// GR-S01: 3 players → priorities 1–3, priority 1 face up.
|
|
|
|
|
|
assert_eq!(state.problems.len(), 3);
|
|
|
|
|
|
assert!(state.problems[&1].face_up);
|
|
|
|
|
|
assert!(!state.problems[&2].face_up);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S03/S04: the same seed reproduces setup exactly; a different
|
|
|
|
|
|
/// seed does not.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn setup_is_seed_deterministic() {
|
|
|
|
|
|
assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42)));
|
|
|
|
|
|
assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R02: one selection per player per round.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn second_selection_is_a_duplicate() {
|
|
|
|
|
|
let mut state = setup_3p(42);
|
|
|
|
|
|
let cmd = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap();
|
|
|
|
|
|
for event in &events {
|
|
|
|
|
|
state.fold(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
state.validate(Actor::Player(PlayerId(0)), &cmd),
|
|
|
|
|
|
Err(Rejection::DuplicateCommand)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending
|
|
|
|
|
|
/// Freedom lifts it.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn stress_gate_blocks_until_freedom_is_spent() {
|
|
|
|
|
|
let mut state = setup_3p(42);
|
|
|
|
|
|
state.players.get_mut(&PlayerId(0)).unwrap().stress = 4;
|
|
|
|
|
|
let support = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Support,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let attack = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
state.validate(Actor::Player(PlayerId(0)), &support),
|
|
|
|
|
|
Err(Rejection::Game { ref code, .. }) if code == "stress-gate"
|
|
|
|
|
|
));
|
|
|
|
|
|
// ATTACK is always admitted by the gate.
|
|
|
|
|
|
assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok());
|
|
|
|
|
|
|
|
|
|
|
|
let spent = state
|
|
|
|
|
|
.validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
for event in &spent {
|
|
|
|
|
|
state.fold(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!state.players[&PlayerId(0)].freedom_ready);
|
|
|
|
|
|
assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-A13: SUPPORT and ATTACK may not target their own player.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn self_targeting_is_rejected() {
|
|
|
|
|
|
let state = setup_3p(42);
|
|
|
|
|
|
let result = state.validate(
|
|
|
|
|
|
Actor::Player(PlayerId(0)),
|
|
|
|
|
|
&GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(0)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
result,
|
|
|
|
|
|
Err(Rejection::Game { ref code, .. }) if code == "bad-target"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|