Some checks failed
ci / check (push) Has been cancelled
The three-armed enum could not express a configuration carrying two
modules. It does now: --variant h1 expands to problem_stress.flat_any_open
AND attack_relief.self_soothe_ge4, and scoped_plus_attack_soothe plays.
Legacy names alias forever via serde(alias="variant") plus a
scalar-or-map deserialiser. serde(default) alone would have been a silent
migration bug -- every H1/H2 recording would have come back as baseline.
All 26 scenarios pass unchanged; none pins a state hash.
Three things shipped broken first, all caught by gates rather than by
reading:
1. `profile` was in the state hash. with_config(select("ground-darvo-r0"))
sets profile=Some("baseline") where setup alone leaves None, so two
states at THE SAME POINT IN ASPECT SPACE hashed differently and a
replay bundle stopped reproducing its own initial state. Now
serde(skip): a hash covers what determines play. This was the open
judgement from T01 and it did not survive contact with the replay path.
2. A YAML parse inside the event loop. rules() -> resolve() -> catalog()
re-parsed catalog.yaml per rule check; AM-6 fell to 9,345 events/s
against a 100,000 target. OnceLock, and aspect validation moved to
where a configuration is BUILT.
Then I nearly optimised a phantom: 470k still looked like a 3x
regression against the "~1.7M on bnt-lap001" reference in the gate's
own message. Making rules() free measured 491k -- this machine's
ceiling. Before optimising against a reference, measure the ceiling
with the suspect code removed.
3. The refusal did not fire on the path a player takes.
`--module problem_deal.pressure_deck` played a full baseline game and
reported success, because with_config is a builder and fell back to
the printed rules -- the silent no-op ADR-0022 exists to refuse. Every
unit test of resolve() passed. The helper was tested and the driver
was not, which is CB-WP-0033's finding verbatim. The new test asserts
refusal BY NAME and that no game was played.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4580 lines
187 KiB
Rust
4580 lines
187 KiB
Rust
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
|
||
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
||
//! comment, giving a greppable rule→code→scenario chain.
|
||
|
||
/// Bots (CB-WP-0008 T01) — the kernel's first non-scenario consumer.
|
||
/// Deliberately **not** gated on `scenarios`: a bot needs only the
|
||
/// aggregate. That its tests do need `scenarios` is a real seam — setup
|
||
/// presets currently live behind that feature (see `bot.rs`).
|
||
pub mod bot;
|
||
/// The vendored edition data (ADR-0011, ADR-0015).
|
||
///
|
||
/// **Not behind `scenarios`.** It was, because its only consumer —
|
||
/// `setup` — is; but the edition is the *game's own data*, and since
|
||
/// CB-WP-0028 the shipped runtime reads it too, to show a player what a
|
||
/// card says. Test machinery and game content are different things and
|
||
/// only one of them is optional.
|
||
pub mod edition;
|
||
|
||
/// ground-game's rules catalog, schema 2 (CB-WP-0048, ADR-0022).
|
||
///
|
||
/// Gated with `scenarios` because it parses YAML and that is where
|
||
/// `serde_yaml` lives. The *identity* type in `config` is not gated: a
|
||
/// configuration must be readable off a recording whether or not this
|
||
/// build can validate it against the catalog.
|
||
#[cfg(feature = "scenarios")]
|
||
pub mod catalog;
|
||
|
||
/// A configuration is a point in aspect space (ADR-0022).
|
||
#[cfg(feature = "scenarios")]
|
||
pub mod config;
|
||
|
||
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first
|
||
/// implementor. Needs the runtime's `Project`, which the game already
|
||
/// depends on, so it is not feature-gated either.
|
||
pub mod view;
|
||
|
||
/// The inverse of `parse_command` (CB-WP-0008 T02): a played game becomes
|
||
/// a scenario. Needs the scenario vocabulary, so it is gated with it.
|
||
#[cfg(feature = "scenarios")]
|
||
pub mod record;
|
||
|
||
/// *Was this deal winnable?* — the retrospective search (CB-WP-0025 T05,
|
||
/// ADR-0013). Uses only `validate`/`fold`/`legal_commands`, so it lives
|
||
/// beside the aggregate rather than in a crate that would re-export it
|
||
/// (ADR-0013 D6).
|
||
pub mod search;
|
||
|
||
#[cfg(feature = "scenarios")]
|
||
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
||
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::BTreeMap;
|
||
|
||
/// GR-O02: per-player state.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PlayerState {
|
||
/// GR-F01: clamped 0–5.
|
||
pub stress: u8,
|
||
/// GR-F03: the Freedom token is READY until spent.
|
||
pub freedom_ready: bool,
|
||
/// GR-R03: set when Freedom is spent this round, lifting the stress
|
||
/// gate for this Select step only. Cleared at round End.
|
||
#[serde(default)]
|
||
pub freedom_gate_lifted: bool,
|
||
/// GR-D01/D02: OFF or the pending/active stage.
|
||
pub darvo: DarvoStage,
|
||
pub hand: Vec<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,
|
||
/// H2-OWN: which seat this Problem's Stress falls on, for a
|
||
/// `personal` or `bond` scope (CB-WP-0042).
|
||
///
|
||
/// **`None` under the baseline, and skipped when `None` so it
|
||
/// serialises exactly as it always did** — every recorded scenario
|
||
/// predates this field, and a state that hashed differently would
|
||
/// break all of them.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub owner: Option<PlayerId>,
|
||
/// H2-SCOPE: who this Problem's End-of-Round Stress falls on.
|
||
///
|
||
/// Stored rather than looked up, because the lookup needs the
|
||
/// `hidden_priority` and `ProblemState` does not carry it — and
|
||
/// deriving the priority from the map key would make the deal order
|
||
/// load-bearing for a rules effect.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub scope: Option<crate::edition::StressScope>,
|
||
}
|
||
|
||
#[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,
|
||
/// Which selectable rules package the kernel is playing
|
||
/// (CB-WP-0038, ground-game `editions/catalog.yaml`).
|
||
///
|
||
/// **In the state, therefore in the hash, therefore in the
|
||
/// recording.** A scenario replayed under a different variant would
|
||
/// diverge silently, and the recording is what every other artifact
|
||
/// rests on. `#[serde(default)]` so every scenario written before
|
||
/// variants existed still loads, as baseline — which is what it was.
|
||
/// Which point in aspect space this game is played at
|
||
/// ([ADR-0022](../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md)).
|
||
///
|
||
/// **Was `variant: Variant`, a three-armed enum** — which could not
|
||
/// express `problem_stress.scoped` × `attack_relief.self_soothe_ge4`,
|
||
/// a profile the catalog ships. Not "unimplemented": unrepresentable.
|
||
///
|
||
/// `alias = "variant"` plus `Configuration`'s scalar-or-map
|
||
/// deserialiser means a recording written before this loads by its
|
||
/// old name and old spelling. **`serde(default)` alone would have
|
||
/// been a silent migration bug**: every recording played under H1 or
|
||
/// H2 would have come back as the baseline.
|
||
#[serde(default, alias = "variant")]
|
||
pub config: crate::config::Configuration,
|
||
/// Which of the edition's four Scenarios is on the table
|
||
/// (CB-WP-0047).
|
||
///
|
||
/// **The engine dealt `SCN_01` and only `SCN_01`** for the whole life
|
||
/// of this repo — the other three decks were vendored, gated, and
|
||
/// never played. Fifteen of the twenty Problem cards had never
|
||
/// reached a table.
|
||
///
|
||
/// In the state for the same reason `variant` is: the threshold and
|
||
/// the whole board follow from it, so a recording that did not carry
|
||
/// it could not be replayed. `#[serde(default)]` returns `SCN_01`,
|
||
/// which is what every recording written before this field existed
|
||
/// actually played.
|
||
#[serde(default = "default_scenario")]
|
||
pub scenario: String,
|
||
/// 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-E01 by seat band, as the engine used to hardcode it.
|
||
///
|
||
/// **Kept as the fallback, and only as the fallback.** All four
|
||
/// scenarios print 5/7/9, so this and the edition agree today — which is
|
||
/// exactly why a test comparing the two numbers proves nothing. See
|
||
/// `threshold_from`.
|
||
fn seat_band_threshold(seats: usize) -> u32 {
|
||
match seats {
|
||
0..=2 => 5,
|
||
3..=4 => 7,
|
||
_ => 9,
|
||
}
|
||
}
|
||
|
||
/// The threshold a Scenario card states, or the seat band if the edition
|
||
/// does not have that card (CB-WP-0047).
|
||
///
|
||
/// **Split out so it can be tested against a card that disagrees.** The
|
||
/// first version read the edition inline, and mutating it back to the
|
||
/// hardcoded bands left every test green — because all four scenarios
|
||
/// print 5/7/9 and the two paths are observationally identical on every
|
||
/// input the edition can supply. A control that cannot separate the
|
||
/// thing it is about from its fallback is not a control; taking a list
|
||
/// as an argument lets one be written.
|
||
#[cfg(feature = "scenarios")]
|
||
fn threshold_from(list: &[crate::edition::ScenarioText], scenario: &str, seats: usize) -> u32 {
|
||
match list.iter().find(|s| s.id == scenario) {
|
||
Some(s) => {
|
||
let (two, three_four, five_six) = s.thresholds;
|
||
match seats {
|
||
0..=2 => two,
|
||
3..=4 => three_four,
|
||
_ => five_six,
|
||
}
|
||
}
|
||
None => seat_band_threshold(seats),
|
||
}
|
||
}
|
||
|
||
/// The scenario every recording written before CB-WP-0047 played.
|
||
///
|
||
/// **Not "the first scenario" — the one that was actually dealt.** The
|
||
/// distinction matters if the edition ever reorders `Scenarios.csv`.
|
||
fn default_scenario() -> String {
|
||
"SCN_01".to_string()
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// A selectable rules package (ground-game `editions/catalog.yaml`).
|
||
///
|
||
/// **Not a difficulty setting and not a preference.** A variant changes
|
||
/// what the rules *are*, so unlike `Pace` it legitimately changes the
|
||
/// outcome, the state hash and the recording — and must therefore be
|
||
/// recorded with the game rather than chosen at render time.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub enum Variant {
|
||
/// `ground-darvo-r0` — the printed baseline, and the default.
|
||
#[default]
|
||
Baseline,
|
||
/// `h2-scoped-problem-stress` — unclaimed Problems tick only the
|
||
/// seats in their scope. **Does not stack with H1** — the package
|
||
/// says `replaces_experiments: [h1-problem-stress]`.
|
||
H2ScopedProblemStress,
|
||
/// `h1-problem-stress` — ground-game's hypothesis H1.
|
||
///
|
||
/// **Experimental.** Two deltas only: unclaimed Problems raise
|
||
/// everyone's Stress at Round End, and a high-Stress attacker gets a
|
||
/// small self-relief.
|
||
H1ProblemStress,
|
||
}
|
||
|
||
impl Variant {
|
||
/// This legacy name as a **configuration** (ADR-0022 D2).
|
||
///
|
||
/// The enum survives as a spelling the maintainer types and every
|
||
/// recording carries; it is no longer what the kernel branches on.
|
||
/// Expansion goes through the catalog's own `legacy_experiment_id`,
|
||
/// so ground-game's mapping has one home.
|
||
pub fn configuration(self) -> crate::config::Configuration {
|
||
crate::config::Configuration::select(self.id()).unwrap_or_default()
|
||
}
|
||
|
||
/// The catalog's `variant_id`, which is how ground-game names these.
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Variant::Baseline => "ground-darvo-r0",
|
||
Variant::H1ProblemStress => "h1-problem-stress",
|
||
Variant::H2ScopedProblemStress => "h2-scoped-problem-stress",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::str::FromStr for Variant {
|
||
type Err = String;
|
||
fn from_str(s: &str) -> Result<Self, String> {
|
||
match s {
|
||
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
|
||
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
|
||
"h2-scoped-problem-stress" | "h2" => Ok(Variant::H2ScopedProblemStress),
|
||
other => Err(format!(
|
||
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress, \
|
||
h2-scoped-problem-stress)"
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl GroundState {
|
||
/// Select the rules package, **and apply whatever its setup needs**
|
||
/// (CB-WP-0042 T02).
|
||
///
|
||
/// **A builder rather than a bare field write.** H2 assigns Problem
|
||
/// owners at setup, and `state.variant = v` would leave them
|
||
/// unassigned — a silently wrong game rather than a failing one.
|
||
/// Every driver path goes through here.
|
||
pub fn with_variant(self, variant: Variant) -> Self {
|
||
self.with_config(variant.configuration())
|
||
}
|
||
|
||
/// Select the configuration, and apply whatever its modules need at
|
||
/// setup (ADR-0022 D3).
|
||
///
|
||
/// **A builder rather than a bare field write.**
|
||
/// `problem_stress.scoped` assigns Problem owners at setup, and
|
||
/// `state.config = c` would leave them unassigned — a silently wrong
|
||
/// game rather than a failing one. That defect had three call sites
|
||
/// under the old enum; with N aspects it is N times as likely, so
|
||
/// every driver path goes through here.
|
||
pub fn with_config(mut self, config: crate::config::Configuration) -> Self {
|
||
let rules = config.resolve();
|
||
self.config = config;
|
||
if let Ok(r) = rules {
|
||
if r.problem_stress == crate::config::ProblemStress::Scoped {
|
||
self.apply_h2_setup();
|
||
}
|
||
}
|
||
self
|
||
}
|
||
|
||
/// What this game's configuration means for the kernel.
|
||
///
|
||
/// **Falls back to the printed rules** when the configuration names
|
||
/// something this kernel cannot run. `setup` and `with_config` are
|
||
/// where that is refused loudly; by the time a rule is being applied
|
||
/// the game is already in progress, and a mid-game panic would turn
|
||
/// a selection error into a crash at an unrelated moment.
|
||
fn rules(&self) -> crate::config::Rules {
|
||
self.config.resolve().unwrap_or_default()
|
||
}
|
||
|
||
/// H2-SCOPE and H2-OWN, applied to the dealt board.
|
||
///
|
||
/// Owners go to the **non-global** Problems in **ascending hidden
|
||
/// priority**, starting at the Lead and stepping clockwise. The
|
||
/// Problems map is keyed 1..n in deal order and `edition::deal`
|
||
/// sorts by priority, so map order *is* priority order — asserted in
|
||
/// `owners_follow_ascending_priority_from_the_lead` rather than
|
||
/// assumed.
|
||
fn apply_h2_setup(&mut self) {
|
||
let Ok(scopes) = crate::edition::stress_scopes("SCN_01") else {
|
||
return;
|
||
};
|
||
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
|
||
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
|
||
let mut next = 0usize;
|
||
// Keys ascend with priority; priority 0 is the Surface.
|
||
for (i, (_, problem)) in self.problems.iter_mut().enumerate() {
|
||
let priority = i as u8;
|
||
let scope = scopes.get(&priority).copied();
|
||
problem.scope = scope;
|
||
if matches!(
|
||
scope,
|
||
Some(crate::edition::StressScope::Personal)
|
||
| Some(crate::edition::StressScope::Bond)
|
||
) {
|
||
problem.owner = Some(seats[(start + next) % seats.len()]);
|
||
next += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 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
|
||
}
|
||
|
||
/// H2-A: the owner plus every seat reachable over **Bond edges
|
||
/// only** — never Rivalry. A degree-0 owner falls back to personal,
|
||
/// which the delta states and which is the branch a test forgets.
|
||
fn bond_network(&self, owner: PlayerId) -> Vec<PlayerId> {
|
||
let mut seen = vec![owner];
|
||
let mut frontier = vec![owner];
|
||
while let Some(seat) = frontier.pop() {
|
||
for (pair, rel) in &self.relations {
|
||
if *rel != Relation::Bond || !pair.contains(seat) {
|
||
continue;
|
||
}
|
||
let other = if pair.0 == seat { pair.1 } else { pair.0 };
|
||
if !seen.contains(&other) {
|
||
seen.push(other);
|
||
frontier.push(other);
|
||
}
|
||
}
|
||
}
|
||
seen
|
||
}
|
||
|
||
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 0–5.
|
||
fn stress_after(&self, player: PlayerId, delta: i16) -> u8 {
|
||
let current = self.players.get(&player).map_or(0, |p| i16::from(p.stress));
|
||
current.saturating_add(delta).clamp(0, 5) as u8
|
||
}
|
||
|
||
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
||
self.players.get(&id).ok_or(Rejection::Game {
|
||
code: "no-such-seat".into(),
|
||
detail: format!("player {id} is not in this game"),
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Aggregate for GroundState {
|
||
type Command = GroundCommand;
|
||
type Event = GroundEvent;
|
||
|
||
fn validate(
|
||
&self,
|
||
actor: Actor,
|
||
command: &Self::Command,
|
||
) -> Result<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 4–5 only ATTACK or GROUND may be selected"
|
||
.into(),
|
||
});
|
||
}
|
||
self.check_targeting(id, *action, *target, *problem)?;
|
||
Ok(vec![GroundEvent::ActionSelected {
|
||
player: id,
|
||
selection: Selection {
|
||
action: *action,
|
||
target: *target,
|
||
problem: *problem,
|
||
},
|
||
}])
|
||
}
|
||
// GR-R03: spendable during Select, before Reveal, once.
|
||
GroundCommand::SpendFreedom => {
|
||
if self.step != RoundStep::Select {
|
||
return Err(Rejection::NotAllowedNow);
|
||
}
|
||
if !player.freedom_ready {
|
||
return Err(Rejection::Game {
|
||
code: "freedom-spent".into(),
|
||
detail: "GR-F03: the Freedom token is already SPENT".into(),
|
||
});
|
||
}
|
||
Ok(vec![GroundEvent::FreedomSpent { player: id }])
|
||
}
|
||
// GR-R05: only after Reveal, only for a player who revealed
|
||
// GROUND, once.
|
||
GroundCommand::ChooseGroundMode { mode, choice } => {
|
||
if self.step != RoundStep::Reveal {
|
||
return Err(Rejection::NotAllowedNow);
|
||
}
|
||
let revealed_ground = self
|
||
.selections
|
||
.get(&id)
|
||
.is_some_and(|s| s.action == Action::Ground);
|
||
if !revealed_ground {
|
||
return Err(Rejection::Game {
|
||
code: "no-ground-revealed".into(),
|
||
detail: "GR-R05: only a player who revealed GROUND chooses a mode".into(),
|
||
});
|
||
}
|
||
if self.ground_modes.contains_key(&id) {
|
||
return Err(Rejection::DuplicateCommand);
|
||
}
|
||
let _ = player;
|
||
// GR-A10..A12: GR takes no sub-choice; OU and ND each
|
||
// require one drawn from their own list.
|
||
match (mode, choice) {
|
||
(GroundMode::Gr, None) => {}
|
||
(GroundMode::Gr, Some(_)) => {
|
||
return Err(Rejection::Game {
|
||
code: "unexpected-choice".into(),
|
||
detail: "GR-A10: GROUND—GR takes no sub-choice".into(),
|
||
})
|
||
}
|
||
(wanted, Some(choice)) if choice.mode() == *wanted => {}
|
||
(wanted, _) => {
|
||
return Err(Rejection::Game {
|
||
code: "bad-choice".into(),
|
||
detail: format!("GR-A11/A12: {wanted:?} needs one of its own choices"),
|
||
})
|
||
}
|
||
}
|
||
self.check_ground_choice(id, *choice)?;
|
||
Ok(vec![GroundEvent::GroundModeChosen {
|
||
player: id,
|
||
mode: *mode,
|
||
choice: *choice,
|
||
}])
|
||
}
|
||
// GR-L02/A05: only the target of a revealed Support answers,
|
||
// once, and only with a response its relation admits.
|
||
GroundCommand::RespondToSupport { response } => {
|
||
if self.step != RoundStep::Reveal {
|
||
return Err(Rejection::NotAllowedNow);
|
||
}
|
||
let supporter = self.selections.iter().find(|(seat, sel)| {
|
||
sel.action == Action::Support && sel.target == Some(id) && **seat != id
|
||
});
|
||
let Some((supporter, _)) = supporter else {
|
||
return Err(Rejection::Game {
|
||
code: "no-support-received".into(),
|
||
detail: "GR-L02: no revealed Support targets this player".into(),
|
||
});
|
||
};
|
||
if self.support_responses.contains_key(&id) {
|
||
return Err(Rejection::DuplicateCommand);
|
||
}
|
||
let admitted = match self.relation_between(*supporter, id) {
|
||
// GR-L02: no relation — the target may accept a Bond.
|
||
None => matches!(
|
||
response,
|
||
SupportResponse::AcceptBond | SupportResponse::DeclineBond
|
||
),
|
||
// GR-A05: through a Rivalry — flip it or break it.
|
||
Some(Relation::Rivalry) => matches!(
|
||
response,
|
||
SupportResponse::FlipToBond | SupportResponse::BreakRivalry
|
||
),
|
||
// GR-A04: through a Bond — nothing to answer.
|
||
Some(Relation::Bond) => false,
|
||
};
|
||
if !admitted {
|
||
return Err(Rejection::Game {
|
||
code: "bad-response".into(),
|
||
detail: format!("GR-L02/A05: {response:?} is not available here"),
|
||
});
|
||
}
|
||
Ok(vec![GroundEvent::SupportAnswered {
|
||
player: id,
|
||
response: *response,
|
||
}])
|
||
}
|
||
// GR-D03/D04: only a player with a live sequence, after
|
||
// Reveal, once per round.
|
||
GroundCommand::ChooseDarvoTarget { target } => {
|
||
if self.step != RoundStep::Reveal {
|
||
return Err(Rejection::NotAllowedNow);
|
||
}
|
||
if player.darvo == DarvoStage::Off {
|
||
return Err(Rejection::Game {
|
||
code: "no-darvo-sequence".into(),
|
||
detail: "GR-D02: this player has no live DARVO sequence".into(),
|
||
});
|
||
}
|
||
if self.darvo_targets.contains_key(&id) {
|
||
return Err(Rejection::DuplicateCommand);
|
||
}
|
||
match player.darvo {
|
||
// GR-D03: a face-up, unsolved, unprotected Problem.
|
||
DarvoStage::Deny => {
|
||
let problem = target.problem.ok_or(Rejection::Game {
|
||
code: "bad-darvo-target".into(),
|
||
detail: "GR-D03: DENY names a Problem".into(),
|
||
})?;
|
||
let eligible = self.problems.get(&problem).is_some_and(|p| {
|
||
p.face_up
|
||
&& !p.denied
|
||
&& p.claimed_by.is_none()
|
||
&& !p.protected_this_round
|
||
});
|
||
if !eligible {
|
||
return Err(Rejection::Game {
|
||
code: "bad-darvo-target".into(),
|
||
detail: format!(
|
||
"GR-D03: Problem {problem} is not face-up, unsolved and unprotected"
|
||
),
|
||
});
|
||
}
|
||
}
|
||
// GR-D04: an extra Attack against another player.
|
||
DarvoStage::Attack => {
|
||
let other = target.player.ok_or(Rejection::Game {
|
||
code: "bad-darvo-target".into(),
|
||
detail: "GR-D04: ATTACK names a player".into(),
|
||
})?;
|
||
if other == id || !self.players.contains_key(&other) {
|
||
return Err(Rejection::Game {
|
||
code: "bad-darvo-target".into(),
|
||
detail: "GR-D04: ATTACK targets another player".into(),
|
||
});
|
||
}
|
||
}
|
||
// GR-D05: REVERSE uses the placed Focus token.
|
||
DarvoStage::Reverse | DarvoStage::Off => {}
|
||
}
|
||
Ok(vec![GroundEvent::DarvoTargetChosen {
|
||
player: id,
|
||
target: *target,
|
||
}])
|
||
}
|
||
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
|
||
unreachable!("system commands are handled above")
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fold(&mut self, event: &Self::Event) {
|
||
match event {
|
||
GroundEvent::ActionSelected { player, selection } => {
|
||
self.selections.insert(*player, *selection);
|
||
}
|
||
GroundEvent::FreedomSpent { player } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.freedom_ready = false;
|
||
state.freedom_gate_lifted = true;
|
||
}
|
||
}
|
||
GroundEvent::Revealed => {}
|
||
GroundEvent::StressSet { player, stress } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.stress = *stress;
|
||
}
|
||
}
|
||
GroundEvent::FreedomReadied { player } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.freedom_ready = true;
|
||
}
|
||
}
|
||
GroundEvent::RelationFormed { pair, relation } => {
|
||
self.relations.insert(*pair, *relation);
|
||
}
|
||
GroundEvent::RelationBroken { pair } => {
|
||
self.relations.remove(pair);
|
||
}
|
||
GroundEvent::AttackCancelled { target, .. } => {
|
||
if let Some(state) = self.players.get_mut(target) {
|
||
state.protection = state.protection.saturating_sub(1);
|
||
}
|
||
}
|
||
GroundEvent::GroundModeChosen {
|
||
player,
|
||
mode,
|
||
choice,
|
||
} => {
|
||
self.ground_modes.insert(*player, *mode);
|
||
if let Some(choice) = choice {
|
||
self.ground_choices.insert(*player, *choice);
|
||
}
|
||
}
|
||
GroundEvent::SupportAnswered { player, response } => {
|
||
self.support_responses.insert(*player, *response);
|
||
}
|
||
GroundEvent::ProblemRestored { problem } => {
|
||
if let Some(state) = self.problems.get_mut(problem) {
|
||
state.denied = false;
|
||
state.face_up = true;
|
||
}
|
||
}
|
||
GroundEvent::ProblemProtected { problem } => {
|
||
if let Some(state) = self.problems.get_mut(problem) {
|
||
state.protected_this_round = true;
|
||
}
|
||
}
|
||
GroundEvent::BlameRemoved { player, owner } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
if let Some(pos) = state.blame_from.iter().position(|o| o == owner) {
|
||
state.blame_from.remove(pos);
|
||
}
|
||
}
|
||
}
|
||
GroundEvent::ProblemRevealed { problem } => {
|
||
if let Some(state) = self.problems.get_mut(problem) {
|
||
state.face_up = true;
|
||
}
|
||
}
|
||
GroundEvent::SolutionDrawn { player, card } => {
|
||
// The deck draws from its end, matching the GR-S02 deal.
|
||
self.solution_deck.pop();
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.hand.push(*card);
|
||
}
|
||
}
|
||
GroundEvent::SolutionDiscarded { player, card } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
if let Some(pos) = state.hand.iter().position(|c| c == card) {
|
||
state.hand.remove(pos);
|
||
}
|
||
}
|
||
self.solution_discard.push(*card);
|
||
}
|
||
GroundEvent::ProblemClaimed { problem, by } => {
|
||
if let Some(state) = self.problems.get_mut(problem) {
|
||
state.claimed_by = Some(*by);
|
||
}
|
||
}
|
||
GroundEvent::DeckReshuffled { order } => {
|
||
self.solution_deck = order.clone();
|
||
self.solution_discard.clear();
|
||
}
|
||
GroundEvent::DarvoTriggered { player } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.darvo = DarvoStage::Deny;
|
||
}
|
||
}
|
||
GroundEvent::DarvoTargetChosen { player, target } => {
|
||
self.darvo_targets.insert(*player, *target);
|
||
}
|
||
GroundEvent::ProblemDenied { problem } => {
|
||
if let Some(state) = self.problems.get_mut(problem) {
|
||
state.denied = true;
|
||
state.face_up = false;
|
||
}
|
||
}
|
||
GroundEvent::FocusPlaced { owner, target } => {
|
||
self.focus.insert(*owner, *target);
|
||
}
|
||
GroundEvent::FocusFlippedToBlame { owner, target } => {
|
||
self.focus.remove(owner);
|
||
if let Some(state) = self.players.get_mut(target) {
|
||
state.blame_from.push(*owner);
|
||
}
|
||
}
|
||
GroundEvent::ProtectionGained { player } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.protection = state.protection.saturating_add(1);
|
||
}
|
||
}
|
||
GroundEvent::DarvoAdvanced { player, stage } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.darvo = *stage;
|
||
}
|
||
}
|
||
GroundEvent::DarvoEnded { player } => {
|
||
if let Some(state) = self.players.get_mut(player) {
|
||
state.darvo = DarvoStage::Off;
|
||
}
|
||
// GR-D06: an unresolved Focus token comes back.
|
||
self.focus.remove(player);
|
||
}
|
||
GroundEvent::GameEnded { outcome } => {
|
||
self.outcome = Some(outcome.clone());
|
||
self.step = RoundStep::End;
|
||
}
|
||
GroundEvent::StepAdvanced { step } => {
|
||
self.step = *step;
|
||
}
|
||
GroundEvent::RoundEnded { round, next_lead } => {
|
||
self.round = *round;
|
||
self.lead = *next_lead;
|
||
self.selections.clear();
|
||
self.ground_modes.clear();
|
||
self.ground_choices.clear();
|
||
self.support_responses.clear();
|
||
self.darvo_targets.clear();
|
||
for state in self.players.values_mut() {
|
||
// GR-R03: the gate lift lasts one Select step only.
|
||
state.freedom_gate_lifted = false;
|
||
}
|
||
for problem in self.problems.values_mut() {
|
||
// GR-A11: OU protection lasts one round.
|
||
problem.protected_this_round = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl GroundState {
|
||
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
|
||
///
|
||
/// Steps run: GROUND (GR-A10..A12), Support (GR-A03..A05), Attack
|
||
/// (GR-A06), DARVO (GR-D02..D07), INVESTIGATE (GR-A01), SOLVE
|
||
/// (GR-A02).
|
||
///
|
||
/// **This comment claimed DARVO and GROUND—OU/ND were "not yet
|
||
/// implemented" until 2026-08-01**, long after both landed with their
|
||
/// scenarios (`gr-d01`…`gr-d06`, `gr-a11`, `gr-a12`). Nothing checks
|
||
/// prose against code, which is the DFD class `make facts-check`
|
||
/// gates for *numbers* and cannot gate for claims like this one.
|
||
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>,
|
||
) {
|
||
// H1-B (CB-WP-0038): read BEFORE anything resolves. The delta
|
||
// says "the attacker's Stress was >= 4 **before this Attack's
|
||
// effects**", and the attacker's own Stress can move during
|
||
// resolution -- so capturing it afterwards would answer a
|
||
// different question.
|
||
let attacker_stress_before = self.players.get(&attacker).map_or(0, |p| p.stress);
|
||
|
||
// 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"));
|
||
}
|
||
|
||
// H1-B: the self-soothe, **after target and relation effects**
|
||
// (`order: after_target_and_relation_effects`) and only on an
|
||
// Attack that actually resolved -- both cancel paths above have
|
||
// already returned.
|
||
//
|
||
// The DARVO extra Attack comes through here too, which the delta
|
||
// requires: "DARVO-stage extra Attack uses the same Attack
|
||
// resolution (so it can self-soothe too if Stress >= 4)".
|
||
if self.rules().attack_relief == crate::config::AttackRelief::SelfSootheGe4
|
||
&& attacker_stress_before >= 4
|
||
{
|
||
let stress = self.stress_after(attacker, -1);
|
||
events.push(GroundEvent::StressSet {
|
||
player: attacker,
|
||
stress,
|
||
});
|
||
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();
|
||
|
||
// H1-A (CB-WP-0038): problem pressure, and it lands BEFORE the
|
||
// DARVO arm check below, because ground-game's delta orders it
|
||
// "+1 Stress, then clamp 0-5, then DARVO arm check as today".
|
||
// Applying it after would make the pressure unable to arm
|
||
// anything for a round -- the opposite of the hypothesis.
|
||
//
|
||
// **"Unclaimed" includes Denied and still-hidden Problems**, and
|
||
// that is the clause a careless reading drops: it is `claimed_by
|
||
// .is_none()`, not "face-up and unsolved".
|
||
//
|
||
// The trigger loop reads `work`, so it sees the new Stress.
|
||
let mut work = self.clone();
|
||
|
||
// H2-A (CB-WP-0042): scoped pressure. Each unclaimed Problem
|
||
// ticks only the seats in ITS scope, and `stacking: true` — two
|
||
// open bond Problems hit the network twice, so this accumulates
|
||
// per Problem rather than applying once.
|
||
if self.rules().problem_stress == crate::config::ProblemStress::Scoped {
|
||
let mut ticks: BTreeMap<PlayerId, i16> = BTreeMap::new();
|
||
for problem in self.problems.values() {
|
||
if problem.claimed_by.is_some() {
|
||
continue;
|
||
}
|
||
let recipients: Vec<PlayerId> = match problem.scope {
|
||
Some(crate::edition::StressScope::Global) => {
|
||
self.players.keys().copied().collect()
|
||
}
|
||
Some(crate::edition::StressScope::Personal) => {
|
||
problem.owner.into_iter().collect()
|
||
}
|
||
Some(crate::edition::StressScope::Bond) => match problem.owner {
|
||
Some(owner) => self.bond_network(owner),
|
||
None => vec![],
|
||
},
|
||
None => vec![],
|
||
};
|
||
for seat in recipients {
|
||
*ticks.entry(seat).or_default() += 1;
|
||
}
|
||
}
|
||
// In seat order, so two seats taking a tick are ordered (U9).
|
||
for seat in self.seat_order() {
|
||
let Some(n) = ticks.get(&seat).copied() else {
|
||
continue;
|
||
};
|
||
let stress = work.stress_after(seat, n);
|
||
let e = GroundEvent::StressSet {
|
||
player: seat,
|
||
stress,
|
||
};
|
||
work.fold(&e);
|
||
events.push(e);
|
||
}
|
||
}
|
||
|
||
if self.rules().problem_stress == crate::config::ProblemStress::FlatAnyOpen
|
||
&& self.problems.values().any(|p| p.claimed_by.is_none())
|
||
{
|
||
for seat in self.seat_order() {
|
||
// `stress_after` clamps 0..=5, which is the delta's
|
||
// "then clamp".
|
||
let stress = work.stress_after(seat, 1);
|
||
let e = GroundEvent::StressSet {
|
||
player: seat,
|
||
stress,
|
||
};
|
||
work.fold(&e);
|
||
events.push(e);
|
||
}
|
||
}
|
||
|
||
// GR-D01: Stress 5 with the marker OFF starts a sequence. In
|
||
// Lead order, so two simultaneous triggers are ordered (U9).
|
||
for seat in work.seat_order() {
|
||
let player = &work.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.
|
||
// **Scored from `work`, not `self`** (CB-REV-0001 #13). H1-A's
|
||
// pressure goes into `work` above, and `score` reads Stress for
|
||
// the GR-E03 and GR-E04 tiebreaks — so scoring from `self` made
|
||
// the final round's pressure invisible to exactly the two modes
|
||
// CB-EV-0030 reports on. Under the baseline `work` is a clone and
|
||
// this is a no-op.
|
||
if self.round >= 5 {
|
||
events.push(GroundEvent::GameEnded {
|
||
outcome: work.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, **off the Scenario card**.
|
||
///
|
||
/// This was `match seats { 0..=2 => 5, 3..=4 => 7, _ => 9 }` — the
|
||
/// engine's own copy of a number four cards already print, which is
|
||
/// exactly what F25 was raised about. It was right only because all
|
||
/// four scenarios happen to agree, and a fifth scenario with a
|
||
/// different threshold would have been scored against the old one
|
||
/// with nothing to say so.
|
||
///
|
||
/// The bands survive as the fallback for a state whose scenario the
|
||
/// edition cannot supply. **That path is unreachable through
|
||
/// `setup`**, which refuses an unknown scenario before dealing, and
|
||
/// a test holds it unreachable — a fallback nothing can reach is the
|
||
/// only kind that cannot silently answer for the real one.
|
||
#[cfg(feature = "scenarios")]
|
||
fn threshold(&self) -> u32 {
|
||
let list = crate::edition::scenarios().unwrap_or_default();
|
||
threshold_from(&list, &self.scenario, self.players.len())
|
||
}
|
||
|
||
/// Without the `scenarios` feature there is no edition to read, so
|
||
/// the seat bands are the whole answer rather than a fallback.
|
||
#[cfg(not(feature = "scenarios"))]
|
||
fn threshold(&self) -> u32 {
|
||
seat_band_threshold(self.players.len())
|
||
}
|
||
|
||
/// GR-E01..E04: final scoring for the configured mode.
|
||
/// **Public since CB-WP-0049 T02.** A policy needs to know what its
|
||
/// seat is trying to maximise, and the game already computes all
|
||
/// three answers here. A second copy in the bot would be a second
|
||
/// definition of winning — and the two would disagree the first time
|
||
/// ground-game rules on F28's mastery reading.
|
||
///
|
||
/// It is a pure function of the state, so it answers mid-game too:
|
||
/// "what would this position score if it stopped now".
|
||
pub 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;
|
||
// **Points, not card count** — F28, ruled by ground-game
|
||
// 2026-08-08 and clarified in `Modes.csv`: *"start from
|
||
// that shared point total and subtract 1 point for each
|
||
// Blame token still in play and 1 point for each Denied
|
||
// Problem (penalties apply to points, not card count)."*
|
||
//
|
||
// This counted CLAIMED CARDS. Both readings fit the
|
||
// sentence as it stood, and they differ on every game
|
||
// where a 3-point Problem is claimed — which is most of
|
||
// them. Reported rather than guessed; the card is the
|
||
// authority (ADR-0015) and it now says so outright.
|
||
let mastery = total as i32 - 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"
|
||
)));
|
||
}
|
||
// GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal
|
||
// only where it can do something. This lives in `validate`
|
||
// and not only in `legal_commands` because a rule enforced
|
||
// by the offer alone is enforced only for clients that ask
|
||
// what is legal — the browser would be filtered and a
|
||
// scenario file would not.
|
||
Action::Solve if target.claimed_by.is_some() => {
|
||
return Err(bad(format!(
|
||
"GR-P05: Problem {problem} was claimed in an earlier round"
|
||
)));
|
||
}
|
||
Action::Solve
|
||
if !self
|
||
.players
|
||
.get(&actor)
|
||
.is_some_and(|p| p.hand.iter().any(|c| c.suit == target.suit)) =>
|
||
{
|
||
return Err(bad(format!(
|
||
"GR-P05: no {:?} Solution in hand for Problem {problem}",
|
||
target.suit
|
||
)));
|
||
}
|
||
_ => {}
|
||
}
|
||
} else if problem.is_some() {
|
||
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// GR-S01's deal now lives in `edition::hidden_depth` (ADR-0011): the
|
||
// ruled shape is Surface + hidden 1..=k, and k belongs beside the data
|
||
// it indexes into.
|
||
|
||
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
|
||
// beside the Problem data it is dealt against.
|
||
|
||
/// Which Scenario a `Setup` preset names, and for how many seats
|
||
/// (CB-WP-0047).
|
||
///
|
||
/// | preset | scenario |
|
||
/// |---|---|
|
||
/// | `standard-4p` | `SCN_01` |
|
||
/// | `scn-03-4p` | `SCN_03` |
|
||
///
|
||
/// **`standard-Np` keeps meaning exactly what it meant**, which is not a
|
||
/// convenience: twenty-six recorded scenarios name it, and a grammar
|
||
/// that redefined it would have moved every one of their boards while
|
||
/// their hashes still claimed to pin them (ADR-0019's discipline, and
|
||
/// the reason `serde(default)` on the field returns `SCN_01`).
|
||
///
|
||
/// The seat count is checked here rather than after the deal, because
|
||
/// `deal` refuses an unknown scenario and a preset naming the wrong seat
|
||
/// count would otherwise be reported as a scenario problem.
|
||
#[cfg(feature = "scenarios")]
|
||
fn parse_preset(preset: &str, seats: u8) -> Result<String, String> {
|
||
let suffix = format!("-{seats}p");
|
||
let Some(head) = preset.strip_suffix(&suffix) else {
|
||
return Err(format!(
|
||
"preset {preset:?} does not match {seats} players \
|
||
(expected {:?} or e.g. {:?})",
|
||
format!("standard{suffix}"),
|
||
format!("scn-02{suffix}"),
|
||
));
|
||
};
|
||
let id = match head {
|
||
"standard" => default_scenario(),
|
||
other => {
|
||
let n = other.strip_prefix("scn-").ok_or_else(|| {
|
||
format!("preset {preset:?}: expected \"standard\" or \"scn-0N\", got {other:?}")
|
||
})?;
|
||
format!("SCN_{n}")
|
||
}
|
||
};
|
||
// **Checked against the edition, not against a pattern.** `SCN_09`
|
||
// matches the shape and is not a scenario; dealing it would fail
|
||
// later with a message about Problems rather than about the preset.
|
||
let known = crate::edition::scenarios()?;
|
||
if !known.iter().any(|s| s.id == id) {
|
||
return Err(format!(
|
||
"preset {preset:?} names {id}, which the edition does not have \
|
||
(it has {})",
|
||
known
|
||
.iter()
|
||
.map(|s| s.id.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
));
|
||
}
|
||
Ok(id)
|
||
}
|
||
|
||
#[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 scenario = parse_preset(&setup.preset, seats)?;
|
||
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
|
||
// priorities 1..=k. The values and suits come from the edition
|
||
// (ADR-0011); the engine used to invent both. **And now the
|
||
// scenario does too** — this argument was the literal `"SCN_01"`
|
||
// for the whole life of the repo (CB-WP-0047).
|
||
let dealt = crate::edition::deal(&scenario, seats)?;
|
||
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
||
|
||
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
||
let mut deck = crate::edition::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![],
|
||
},
|
||
);
|
||
}
|
||
|
||
// Surface is dealt face up; hidden Problems face down (GR-S01).
|
||
// Keyed by 1-based position so scenario dot-paths stay stable.
|
||
let problems: BTreeMap<u32, ProblemState> = dealt
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, p)| {
|
||
(
|
||
(i + 1) as u32,
|
||
ProblemState {
|
||
suit: p.suit,
|
||
value: p.value,
|
||
face_up: p.surface,
|
||
denied: false,
|
||
claimed_by: None,
|
||
protected_this_round: false,
|
||
owner: None,
|
||
scope: None,
|
||
},
|
||
)
|
||
})
|
||
.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,
|
||
scenario,
|
||
// Baseline. The driver overwrites this after setup and
|
||
// before the hash is taken, which is the route `mode` uses
|
||
// (`table.rs`) — so a recorded session replays under the
|
||
// variant it was played under.
|
||
config: crate::config::Configuration::default(),
|
||
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 {
|
||
/// CB-WP-0038 T02 — the H1 deltas, and ground-game's own claim about
|
||
/// what they leave alone.
|
||
mod h1 {
|
||
use super::super::*;
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
|
||
fn setup(players: u8, variant: Variant, seed: u64) -> GroundState {
|
||
let mut s = GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
seed,
|
||
)
|
||
.expect("setup");
|
||
s = s.with_variant(variant);
|
||
s
|
||
}
|
||
|
||
/// **The load-bearing control**, rewritten after review found the
|
||
/// first version inert.
|
||
///
|
||
/// It compared two states built by the *same* `setup` call, both
|
||
/// then assigned `Variant::Baseline` — bitwise identical by
|
||
/// construction, so the assertion could only fail if hashing were
|
||
/// nondeterministic. It carried no information about variants at
|
||
/// all. Adding `#[serde(skip)]` to `variant` — which is exactly
|
||
/// the "a scenario replayed under the wrong variant diverges
|
||
/// silently" failure the workplan named — left all 57 tests green.
|
||
///
|
||
/// This asserts the two properties that were claimed:
|
||
/// **the variant reaches the hash**, and **selecting the baseline
|
||
/// is not a change**.
|
||
#[test]
|
||
fn the_variant_reaches_the_hash_and_baseline_is_not_a_change() {
|
||
for players in [2u8, 3, 6] {
|
||
for seed in 0..8u64 {
|
||
let base = setup(players, Variant::Baseline, seed);
|
||
let h1 = setup(players, Variant::H1ProblemStress, seed);
|
||
|
||
// 1. The variant is IN the hash. `#[serde(skip)]` on
|
||
// the field makes these equal, which is the whole
|
||
// silent-divergence failure.
|
||
assert_ne!(
|
||
cb_events::state_hash_hex(&base),
|
||
cb_events::state_hash_hex(&h1),
|
||
"{players}p seed {seed}: the variant does not reach the state hash, \
|
||
so a recording cannot say which rules it was played under"
|
||
);
|
||
|
||
// 2. And selecting the baseline explicitly is not a
|
||
// change from selecting nothing.
|
||
let untouched = GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
seed,
|
||
)
|
||
.expect("setup");
|
||
assert_eq!(
|
||
untouched.config,
|
||
crate::config::Configuration::default(),
|
||
"the default moved"
|
||
);
|
||
assert_eq!(
|
||
cb_events::state_hash_hex(&base),
|
||
cb_events::state_hash_hex(&untouched),
|
||
"{players}p seed {seed}: selecting the baseline changed it"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The hazard `with_variant` exists for, made visible.**
|
||
///
|
||
/// Setting `state.variant = H2` directly compiles and produces a
|
||
/// game where **no Problem has a scope**, so scoped pressure ticks
|
||
/// nobody and H2 measures as inert — a silently wrong result, not
|
||
/// a failure.
|
||
///
|
||
/// Three call sites had the bare write when this was found, one of
|
||
/// them the driver. This test states the difference so a
|
||
/// regression is caught by a name rather than by a puzzled reader
|
||
/// wondering why H2 did nothing.
|
||
#[test]
|
||
fn a_bare_variant_write_leaves_h2_inert() {
|
||
let base = GroundState::setup(
|
||
&Setup {
|
||
players: 4,
|
||
preset: "standard-4p".into(),
|
||
patch: Default::default(),
|
||
},
|
||
5,
|
||
)
|
||
.expect("setup");
|
||
|
||
// The wrong way.
|
||
let mut bare = base.clone();
|
||
bare.config = Variant::H2ScopedProblemStress.configuration();
|
||
assert!(
|
||
bare.problems.values().all(|p| p.scope.is_none()),
|
||
"a bare write should leave scopes unassigned — if it does not, \
|
||
`with_variant` is no longer load-bearing and this test is stale"
|
||
);
|
||
assert!(
|
||
!bare
|
||
.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||
"a bare-write H2 produced pressure, so the hazard has changed shape"
|
||
);
|
||
|
||
// The right way.
|
||
let built = base.with_variant(Variant::H2ScopedProblemStress);
|
||
assert!(
|
||
built.problems.values().all(|p| p.scope.is_some()),
|
||
"`with_variant` did not assign scopes"
|
||
);
|
||
assert!(
|
||
built
|
||
.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||
"`with_variant` H2 produced no pressure with Problems unclaimed"
|
||
);
|
||
}
|
||
|
||
/// **The baseline is untouched by H2's new state** (T02).
|
||
///
|
||
/// `owner` and `scope` are new fields on `ProblemState`. Both are
|
||
/// `None` under the baseline and skipped when `None`, so a
|
||
/// baseline state must serialise and hash exactly as it did
|
||
/// before they existed — **every recorded scenario predates
|
||
/// them.**
|
||
#[test]
|
||
fn h2s_new_state_is_invisible_under_the_baseline() {
|
||
for players in [2u8, 3, 6] {
|
||
let s = GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
5,
|
||
)
|
||
.expect("setup")
|
||
.with_variant(Variant::Baseline);
|
||
assert!(
|
||
s.problems
|
||
.values()
|
||
.all(|p| p.owner.is_none() && p.scope.is_none()),
|
||
"{players}p: the baseline acquired owners or scopes"
|
||
);
|
||
let json = serde_json::to_string(&s).expect("json");
|
||
assert!(
|
||
!json.contains("\"owner\"") && !json.contains("\"scope\""),
|
||
"{players}p: the baseline serialises H2's fields, so every \
|
||
recorded scenario's hash has moved"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **H2-OWN: ascending priority, from the Lead, clockwise** (T02).
|
||
#[test]
|
||
fn owners_follow_ascending_priority_from_the_lead() {
|
||
for players in [2u8, 3, 4, 5, 6] {
|
||
let s = setup(players, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let start = seats.iter().position(|x| *x == s.lead).expect("lead seats");
|
||
|
||
// The Surface is global and owns nothing; the rest are
|
||
// owned in map order, which is priority order.
|
||
let mut expected = 0usize;
|
||
for (key, p) in &s.problems {
|
||
match p.scope {
|
||
Some(crate::edition::StressScope::Global) => {
|
||
assert!(
|
||
p.owner.is_none(),
|
||
"{players}p: a global Problem has an owner"
|
||
);
|
||
assert_eq!(*key, 1, "the Surface should be the first key");
|
||
}
|
||
Some(_) => {
|
||
assert_eq!(
|
||
p.owner,
|
||
Some(seats[(start + expected) % seats.len()]),
|
||
"{players}p problem {key}: owner is not the next seat \
|
||
clockwise from the Lead"
|
||
);
|
||
expected += 1;
|
||
}
|
||
None => panic!("{players}p problem {key}: H2 left a Problem unscoped"),
|
||
}
|
||
}
|
||
assert!(expected > 0, "{players}p: nothing was owned");
|
||
|
||
// Exactly one owner each, and the walk did not skip.
|
||
let owned = s.problems.values().filter(|p| p.owner.is_some()).count();
|
||
assert_eq!(owned, expected, "{players}p: an owner was assigned twice");
|
||
}
|
||
}
|
||
|
||
/// **Deterministic, as the delta requires** — "deterministic for
|
||
/// sims". A seeded-but-unstated order would be untestable.
|
||
#[test]
|
||
fn h2_ownership_is_deterministic() {
|
||
for seed in [1u64, 7, 99] {
|
||
let a = setup(4, Variant::H2ScopedProblemStress, seed);
|
||
let b = setup(4, Variant::H2ScopedProblemStress, seed);
|
||
let owners = |s: &GroundState| -> Vec<Option<PlayerId>> {
|
||
s.problems.values().map(|p| p.owner).collect()
|
||
};
|
||
assert_eq!(
|
||
owners(&a),
|
||
owners(&b),
|
||
"seed {seed}: ownership is not stable"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **H2-A: each scope reaches exactly who it should** (T03).
|
||
///
|
||
/// Each case is built so one scope is unclaimed and the rest are
|
||
/// claimed, so the recipients are unambiguous.
|
||
#[test]
|
||
fn h2_pressure_reaches_only_the_scope() {
|
||
use crate::edition::StressScope;
|
||
|
||
let ticks = |scope: StressScope, bonds: &[(usize, usize)]| {
|
||
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
for (a, b) in bonds {
|
||
s.relations
|
||
.insert(Pair::new(seats[*a], seats[*b]), Relation::Bond);
|
||
}
|
||
// Claim everything, then re-open exactly one Problem of
|
||
// the scope under test — with a known owner, seat 0.
|
||
let keys: Vec<u32> = s.problems.keys().copied().collect();
|
||
for k in &keys {
|
||
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
|
||
}
|
||
let target = *keys
|
||
.iter()
|
||
.find(|k| s.problems[k].scope == Some(scope))
|
||
.unwrap_or_else(|| panic!("no {scope:?} Problem in the deal"));
|
||
let p = s.problems.get_mut(&target).expect("p");
|
||
p.claimed_by = None;
|
||
p.owner = Some(seats[0]);
|
||
|
||
let mut out: Vec<(PlayerId, u8)> = s
|
||
.end_round_events()
|
||
.into_iter()
|
||
.filter_map(|e| match e {
|
||
GroundEvent::StressSet { player, stress } => Some((player, stress)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
out.sort();
|
||
(seats, out)
|
||
};
|
||
|
||
// global: everyone.
|
||
let (seats, out) = ticks(StressScope::Global, &[]);
|
||
assert_eq!(out.len(), seats.len(), "global did not reach every seat");
|
||
|
||
// personal: the owner alone.
|
||
let (seats, out) = ticks(StressScope::Personal, &[]);
|
||
assert_eq!(
|
||
out.len(),
|
||
1,
|
||
"personal reached more than the owner: {out:?}"
|
||
);
|
||
assert_eq!(out[0].0, seats[0]);
|
||
|
||
// bond: the owner's network, over BOND edges only.
|
||
let (seats, out) = ticks(StressScope::Bond, &[(0, 1), (1, 2)]);
|
||
let hit: Vec<PlayerId> = out.iter().map(|(p, _)| *p).collect();
|
||
assert!(
|
||
hit.contains(&seats[0]) && hit.contains(&seats[1]) && hit.contains(&seats[2]),
|
||
"bond did not traverse the network: {hit:?}"
|
||
);
|
||
assert!(
|
||
!hit.contains(&seats[3]),
|
||
"bond reached a seat outside the network: {hit:?}"
|
||
);
|
||
}
|
||
|
||
/// **Rivalry edges are not the network** (T03) — the single most
|
||
/// likely defect, named in the workplan before the code existed.
|
||
#[test]
|
||
fn h2_bond_scope_does_not_traverse_rivalry() {
|
||
use crate::edition::StressScope;
|
||
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
// 0—1 Bond, 1—2 RIVALRY. The network is {0,1}, never 2.
|
||
s.relations
|
||
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
|
||
s.relations
|
||
.insert(Pair::new(seats[1], seats[2]), Relation::Rivalry);
|
||
let keys: Vec<u32> = s.problems.keys().copied().collect();
|
||
for k in &keys {
|
||
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
|
||
}
|
||
let target = *keys
|
||
.iter()
|
||
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
|
||
.expect("a bond Problem");
|
||
let p = s.problems.get_mut(&target).expect("p");
|
||
p.claimed_by = None;
|
||
p.owner = Some(seats[0]);
|
||
|
||
let hit: Vec<PlayerId> = s
|
||
.end_round_events()
|
||
.into_iter()
|
||
.filter_map(|e| match e {
|
||
GroundEvent::StressSet { player, .. } => Some(player),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(
|
||
!hit.contains(&seats[2]),
|
||
"the Bond network crossed a Rivalry edge: {hit:?}"
|
||
);
|
||
}
|
||
|
||
/// **A degree-0 owner falls back to personal** (T03) — the branch
|
||
/// a test forgets, named in advance.
|
||
#[test]
|
||
fn h2_a_bond_problem_with_no_bonds_is_personal() {
|
||
use crate::edition::StressScope;
|
||
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
assert!(s.relations.is_empty(), "the fixture needs no relations");
|
||
let keys: Vec<u32> = s.problems.keys().copied().collect();
|
||
for k in &keys {
|
||
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
|
||
}
|
||
let target = *keys
|
||
.iter()
|
||
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
|
||
.expect("a bond Problem");
|
||
let p = s.problems.get_mut(&target).expect("p");
|
||
p.claimed_by = None;
|
||
p.owner = Some(seats[0]);
|
||
|
||
let hit: Vec<PlayerId> = s
|
||
.end_round_events()
|
||
.into_iter()
|
||
.filter_map(|e| match e {
|
||
GroundEvent::StressSet { player, .. } => Some(player),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(hit, vec![seats[0]], "a bondless owner should tick alone");
|
||
}
|
||
|
||
/// **`stacking: true`** (T03): two open Problems hitting the same
|
||
/// seat tick it **twice**. A careless implementation applies the
|
||
/// pressure once.
|
||
#[test]
|
||
fn h2_pressure_stacks_per_problem() {
|
||
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let keys: Vec<u32> = s.problems.keys().copied().collect();
|
||
for k in &keys {
|
||
let p = s.problems.get_mut(k).expect("p");
|
||
p.claimed_by = Some(seats[0]);
|
||
}
|
||
// Two personal Problems, both owned by seat 0, both open.
|
||
let personal: Vec<u32> = keys
|
||
.iter()
|
||
.copied()
|
||
.filter(|k| s.problems[k].scope == Some(crate::edition::StressScope::Personal))
|
||
.take(2)
|
||
.collect();
|
||
assert_eq!(personal.len(), 2, "the deal needs two personal Problems");
|
||
for k in &personal {
|
||
let p = s.problems.get_mut(k).expect("p");
|
||
p.claimed_by = None;
|
||
p.owner = Some(seats[0]);
|
||
}
|
||
s.players.get_mut(&seats[0]).expect("s").stress = 0;
|
||
|
||
let got: Vec<(PlayerId, u8)> = s
|
||
.end_round_events()
|
||
.into_iter()
|
||
.filter_map(|e| match e {
|
||
GroundEvent::StressSet { player, stress } => Some((player, stress)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
got,
|
||
vec![(seats[0], 2)],
|
||
"two open Problems on one seat must tick it twice, not once"
|
||
);
|
||
}
|
||
|
||
/// **The baseline and H1 do not feel H2-A** (T03).
|
||
#[test]
|
||
fn h2_pressure_does_not_reach_the_other_variants() {
|
||
for variant in [Variant::Baseline, Variant::H1ProblemStress] {
|
||
let mut s = setup(3, variant, 5);
|
||
// H1 would fire on an unclaimed Problem; claim them all so
|
||
// only H2-A could produce anything here.
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let keys: Vec<u32> = s.problems.keys().copied().collect();
|
||
for k in &keys {
|
||
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
|
||
}
|
||
assert!(
|
||
!s.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||
"{variant:?} gained scoped pressure"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **H2-SOLVE: ownership is not a permission** (T04).
|
||
///
|
||
/// *"Any seat with a matching suit may solve; the owner need not
|
||
/// be the solver"* — and altruistic clearing is **intended**,
|
||
/// especially on bond-scope cards.
|
||
///
|
||
/// **This is a regression test, not a feature.** The engine had
|
||
/// no owner concept, so it was already true; T02 added ownership,
|
||
/// and the risk is that ownership silently becomes a right.
|
||
#[test]
|
||
fn h2_ownership_does_not_restrict_who_may_solve() {
|
||
let s = setup(4, Variant::H2ScopedProblemStress, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
|
||
// A Problem owned by someone else, face up and unclaimed.
|
||
let (key, owner) = s
|
||
.problems
|
||
.iter()
|
||
.find_map(|(k, p)| p.owner.map(|o| (*k, o)))
|
||
.expect("H2 assigns owners");
|
||
let mut s = s;
|
||
s.problems.get_mut(&key).expect("p").face_up = true;
|
||
let suit = s.problems[&key].suit;
|
||
|
||
for seat in &seats {
|
||
// Give every seat the matching card.
|
||
s.players.get_mut(seat).expect("p").hand = vec![SolutionCard { suit }];
|
||
}
|
||
for seat in &seats {
|
||
let legal = crate::bot::legal_commands(&s, *seat);
|
||
let can_solve = legal.iter().any(|c| {
|
||
matches!(
|
||
c,
|
||
GroundCommand::SelectAction {
|
||
action: Action::Solve,
|
||
problem: Some(p),
|
||
..
|
||
} if *p == key
|
||
)
|
||
});
|
||
assert!(
|
||
can_solve,
|
||
"{seat:?} holds the matching suit but may not SOLVE problem {key} \
|
||
(owned by {owner:?}) — ownership has become a permission"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **H1-A.** Unclaimed Problems raise everyone's Stress at Round
|
||
/// End — and "unclaimed" includes Denied and still-hidden, which
|
||
/// is the clause a careless reading drops.
|
||
#[test]
|
||
fn h1a_pressure_applies_while_any_problem_is_unclaimed() {
|
||
let mut s = setup(3, Variant::H1ProblemStress, 7);
|
||
// A fresh deal has unclaimed Problems by construction.
|
||
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
|
||
// One hidden, one Denied: neither is "face-up unsolved", and
|
||
// both must still count.
|
||
let ids: Vec<u32> = s.problems.keys().copied().collect();
|
||
s.problems.get_mut(&ids[0]).expect("p").face_up = false;
|
||
s.problems.get_mut(&ids[1]).expect("p").denied = true;
|
||
|
||
let before: Vec<u8> = s.players.values().map(|p| p.stress).collect();
|
||
let events = s.end_round_events();
|
||
let bumped: Vec<&GroundEvent> = events
|
||
.iter()
|
||
.filter(|e| matches!(e, GroundEvent::StressSet { .. }))
|
||
.collect();
|
||
assert_eq!(
|
||
bumped.len(),
|
||
s.players.len(),
|
||
"every player takes the pressure, not just some"
|
||
);
|
||
for e in bumped {
|
||
if let GroundEvent::StressSet { player, stress } = e {
|
||
let was = s.players[player].stress;
|
||
assert_eq!(*stress, (was + 1).min(5), "clamped 0..=5");
|
||
}
|
||
}
|
||
let _ = before;
|
||
}
|
||
|
||
/// No unclaimed Problem, no pressure — the `when` clause is a
|
||
/// condition, not decoration.
|
||
#[test]
|
||
fn h1a_is_silent_once_every_problem_is_claimed() {
|
||
let mut s = setup(3, Variant::H1ProblemStress, 7);
|
||
let me = *s.players.keys().next().expect("seat");
|
||
for p in s.problems.values_mut() {
|
||
p.claimed_by = Some(me);
|
||
}
|
||
assert!(
|
||
!s.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||
"pressure applied with nothing left unclaimed"
|
||
);
|
||
}
|
||
|
||
/// **The baseline must not feel H1-A at all.**
|
||
#[test]
|
||
fn h1a_does_not_touch_the_baseline() {
|
||
let s = setup(3, Variant::Baseline, 7);
|
||
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
|
||
assert!(
|
||
!s.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||
"the baseline gained problem pressure"
|
||
);
|
||
}
|
||
|
||
fn attack(variant: Variant, attacker_stress: u8, protect_target: bool) -> Vec<GroundEvent> {
|
||
let mut s = setup(3, variant, 3);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let (a, t) = (seats[0], seats[1]);
|
||
s.players.get_mut(&a).expect("a").stress = attacker_stress;
|
||
s.players.get_mut(&t).expect("t").protection = u8::from(protect_target);
|
||
let mut events = Vec::new();
|
||
s.resolve_attack(a, t, &Default::default(), &mut events);
|
||
events
|
||
.into_iter()
|
||
.filter(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a))
|
||
.collect()
|
||
}
|
||
|
||
/// **H1-B.** A high-Stress attacker who actually lands an Attack
|
||
/// gets a small self-relief.
|
||
#[test]
|
||
fn h1b_soothes_only_a_landed_attack_from_high_stress() {
|
||
// Stress 4, uncancelled: soothed.
|
||
let soothed = attack(Variant::H1ProblemStress, 4, false);
|
||
assert_eq!(soothed.len(), 1, "no self-soothe at Stress 4");
|
||
if let GroundEvent::StressSet { stress, .. } = soothed[0] {
|
||
assert_eq!(stress, 3, "the delta is -1");
|
||
}
|
||
// Below the threshold: nothing.
|
||
assert!(
|
||
attack(Variant::H1ProblemStress, 3, false).is_empty(),
|
||
"soothed below Stress 4"
|
||
);
|
||
// Cancelled by Protection: nothing. "Not cancelled" is a
|
||
// condition of the delta, and Protection is a cancel path.
|
||
assert!(
|
||
attack(Variant::H1ProblemStress, 4, true).is_empty(),
|
||
"a cancelled Attack still soothed the attacker"
|
||
);
|
||
// And the baseline never soothes.
|
||
assert!(
|
||
attack(Variant::Baseline, 4, false).is_empty(),
|
||
"the baseline gained the self-soothe"
|
||
);
|
||
}
|
||
|
||
/// **Commit/reveal IS the extensive-form encoding of simultaneous
|
||
/// moves** (CB-WP-0041 T03), and this checks it rather than
|
||
/// asserting it.
|
||
///
|
||
/// An EFG has no simultaneity: the textbook encoding sequences
|
||
/// the moves and puts the later mover in an information set that
|
||
/// cannot see the earlier one. GROUND's Select step does exactly
|
||
/// that — seats choose in order, and no seat may see another's
|
||
/// selection until Reveal.
|
||
///
|
||
/// **The property is load-bearing.** If a seat can see another's
|
||
/// pending selection, the encoding is not simultaneous, the
|
||
/// information partition is wrong, and every equilibrium concept
|
||
/// computed on it answers a different game.
|
||
#[test]
|
||
fn a_pending_selection_is_hidden_until_reveal() {
|
||
use cb_game_runtime::{Project, Viewer};
|
||
|
||
let mut s = setup(4, Variant::Baseline, 9);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
// Every seat commits.
|
||
for seat in &seats {
|
||
s.selections.insert(
|
||
*seat,
|
||
Selection {
|
||
action: Action::Investigate,
|
||
target: None,
|
||
problem: s.problems.keys().next().copied(),
|
||
},
|
||
);
|
||
}
|
||
|
||
// Before Reveal: a seat sees its own and nobody else's.
|
||
s.step = RoundStep::Select;
|
||
for viewer in &seats {
|
||
let v = s.project(Viewer::Player(*viewer));
|
||
for (seat, shown) in &v.selections {
|
||
let visible = matches!(shown, crate::view::SelectionView::Shown(_));
|
||
assert_eq!(
|
||
visible,
|
||
seat == viewer,
|
||
"before Reveal, {viewer:?} could see {seat:?}'s selection — \
|
||
the Select step is not simultaneous and the information \
|
||
partition is wrong"
|
||
);
|
||
}
|
||
}
|
||
|
||
// After Reveal: public, which is the other half of the
|
||
// encoding — the information sets must MERGE, or the reveal
|
||
// never happened.
|
||
s.step = RoundStep::Reveal;
|
||
for viewer in &seats {
|
||
let v = s.project(Viewer::Player(*viewer));
|
||
assert!(
|
||
v.selections
|
||
.values()
|
||
.all(|x| matches!(x, crate::view::SelectionView::Shown(_))),
|
||
"after Reveal, {viewer:?} still cannot see every selection"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **All chance derives from the root seed** (CB-WP-0041 T02).
|
||
///
|
||
/// The claim T02 rests on, made checkable rather than asserted:
|
||
/// a game is determined by `(seed, players, mode, variant)`, so
|
||
/// in extensive-form terms the tree has **a single chance node at
|
||
/// the root** rather than chance distributed through it.
|
||
///
|
||
/// Three chance points exist and all three read that seed:
|
||
/// the setup deck shuffle, the setup Lead draw, and the mid-game
|
||
/// reshuffle permutation (`seed ^ round`). The **Problems deal is
|
||
/// not chance at all** — `edition::deal` is a pure function of the
|
||
/// vendored CSV.
|
||
#[test]
|
||
fn a_game_is_determined_by_its_seed() {
|
||
for players in [2u8, 3, 6] {
|
||
// **The dealt configuration, NOT the state hash.**
|
||
// `GroundState` carries `seed` as a field, so hashing the
|
||
// state makes "different seeds differ" true by
|
||
// construction — a wrong-subject error, caught by
|
||
// mutating the shuffle away and watching this stay green.
|
||
let at = |seed: u64| {
|
||
let s = setup(players, Variant::Baseline, seed);
|
||
let hands: Vec<Vec<Suit>> = s
|
||
.players
|
||
.values()
|
||
.map(|p| p.hand.iter().map(|c| c.suit).collect())
|
||
.collect();
|
||
format!("{:?}|{:?}|{:?}", hands, s.lead, s.solution_deck)
|
||
};
|
||
// Same seed, same game — twice, because "deterministic"
|
||
// that only holds once is not determinism.
|
||
assert_eq!(at(11), at(11), "{players}p: setup is not reproducible");
|
||
// And the full state still round-trips, which is the
|
||
// replay property this rests on.
|
||
assert_eq!(
|
||
cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)),
|
||
cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)),
|
||
);
|
||
// Different seeds must actually differ, or the seed is
|
||
// not the chance node and this claim is empty.
|
||
let distinct: std::collections::BTreeSet<String> = (0..12u64).map(at).collect();
|
||
assert!(
|
||
distinct.len() > 1,
|
||
"{players}p: every seed produced the same game, so the root \
|
||
chance node carries no information"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **The reshuffle is correlated with the root seed, and a real
|
||
/// table's is not** (CB-WP-0041 T02).
|
||
///
|
||
/// `draw_solution` reshuffles the discard with
|
||
/// `ChaChaRng::from_seed(seed ^ round)` — deliberate, so replay
|
||
/// never re-derives it (GameKernel K5), and the comment says so.
|
||
///
|
||
/// **The consequence is a modelling one, not a defect.** At a
|
||
/// table the reshuffle is an independent random event; here it is
|
||
/// determined by the initial shuffle and the round number. An
|
||
/// extensive-form game built from this engine inherits that
|
||
/// correlation, and would be modelling a *restriction* of the
|
||
/// game as played.
|
||
#[test]
|
||
fn the_reshuffle_permutation_is_a_function_of_seed_and_round() {
|
||
let deck: Vec<SolutionCard> = [Suit::Clarify, Suit::Repair, Suit::Boundary]
|
||
.into_iter()
|
||
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 3))
|
||
.collect();
|
||
let shuffled = |seed: u64, round: u8| {
|
||
let mut order = deck.clone();
|
||
let mut rng = ChaChaRng::from_seed(Seed(seed ^ u64::from(round)));
|
||
rng.shuffle(&mut order);
|
||
order
|
||
};
|
||
// Same seed and round: the same permutation, always.
|
||
assert_eq!(shuffled(7, 2), shuffled(7, 2));
|
||
// And it moves with BOTH inputs, or the correlation claim is
|
||
// about something that does not vary.
|
||
assert_ne!(
|
||
shuffled(7, 2),
|
||
shuffled(8, 2),
|
||
"the reshuffle does not depend on the seed"
|
||
);
|
||
assert_ne!(
|
||
shuffled(7, 2),
|
||
shuffled(7, 3),
|
||
"the reshuffle does not depend on the round"
|
||
);
|
||
}
|
||
|
||
/// **H1-A lands before the DARVO arm check** (review M5).
|
||
///
|
||
/// The delta orders it "+1 Stress, then clamp, then DARVO arm
|
||
/// check as today". CB-WP-0038 claimed this was got right and
|
||
/// nothing tested it: moving the pressure after the arm check
|
||
/// left every test green.
|
||
///
|
||
/// A seat at 4 with a Problem unclaimed must arm **in the same
|
||
/// round end** — pressure takes it to 5, and the check sees it.
|
||
#[test]
|
||
fn h1a_pressure_arms_darvo_in_the_same_round_end() {
|
||
let mut s = setup(3, Variant::H1ProblemStress, 7);
|
||
for p in s.players.values_mut() {
|
||
p.stress = 4;
|
||
p.darvo = DarvoStage::Off;
|
||
}
|
||
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
|
||
let events = s.end_round_events();
|
||
assert!(
|
||
events
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::DarvoTriggered { .. })),
|
||
"pressure took the seat to 5 but the arm check did not see it, \
|
||
so H1-A is ordered after it"
|
||
);
|
||
// And the order is visible in the event stream: the Stress
|
||
// must be set before the trigger, not after.
|
||
let first_stress = events
|
||
.iter()
|
||
.position(|e| matches!(e, GroundEvent::StressSet { .. }));
|
||
let first_trigger = events
|
||
.iter()
|
||
.position(|e| matches!(e, GroundEvent::DarvoTriggered { .. }));
|
||
assert!(
|
||
first_stress < first_trigger,
|
||
"the arm check precedes the pressure in the event stream"
|
||
);
|
||
}
|
||
|
||
/// **H1-B must not fire on a GROUND—OU cancellation** (review M11).
|
||
///
|
||
/// `resolve_attack` has two cancel paths and the existing test
|
||
/// exercised only Protection — it passed `&Default::default()`
|
||
/// for `ou_cancels`, so the other path was never reached.
|
||
#[test]
|
||
fn h1b_does_not_soothe_an_ou_cancelled_attack() {
|
||
let mut s = setup(3, Variant::H1ProblemStress, 3);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let (a, t) = (seats[0], seats[1]);
|
||
s.players.get_mut(&a).expect("a").stress = 4;
|
||
let mut ou = std::collections::BTreeSet::new();
|
||
ou.insert((a, t));
|
||
let mut events = Vec::new();
|
||
s.resolve_attack(a, t, &ou, &mut events);
|
||
assert!(
|
||
!events
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a)),
|
||
"an Attack cancelled by GROUND—OU still soothed the attacker"
|
||
);
|
||
}
|
||
|
||
/// **H1-B applies to the DARVO extra Attack** (review M12).
|
||
///
|
||
/// The delta says so outright — *"DARVO-stage extra Attack uses
|
||
/// the same Attack resolution (so it can self-soothe too if
|
||
/// Stress >= 4)"* — and CB-WP-0038 asserted it on the strength of
|
||
/// the code sharing `resolve_attack`. **Nothing tested it**, and
|
||
/// CB-EV-0031's withdrawn mechanism story ran through this path.
|
||
#[test]
|
||
fn h1b_soothes_the_darvo_stage_attack_too() {
|
||
let soothe_count = |variant: Variant| {
|
||
let mut s = setup(3, variant, 5);
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let (owner, target) = (seats[0], seats[1]);
|
||
s.step = RoundStep::Resolve;
|
||
s.players.get_mut(&owner).expect("o").stress = 4;
|
||
s.players.get_mut(&owner).expect("o").darvo = DarvoStage::Attack;
|
||
s.darvo_targets.insert(
|
||
owner,
|
||
DarvoTarget {
|
||
problem: None,
|
||
player: Some(target),
|
||
},
|
||
);
|
||
s.resolution_events()
|
||
.iter()
|
||
.filter(|e| {
|
||
matches!(e, GroundEvent::StressSet { player, stress }
|
||
if *player == owner && *stress == 3)
|
||
})
|
||
.count()
|
||
};
|
||
assert_eq!(
|
||
soothe_count(Variant::H1ProblemStress),
|
||
1,
|
||
"the DARVO extra Attack did not self-soothe, though the delta says it \
|
||
shares the same Attack resolution"
|
||
);
|
||
assert_eq!(
|
||
soothe_count(Variant::Baseline),
|
||
0,
|
||
"the baseline soothed on a DARVO stage Attack"
|
||
);
|
||
}
|
||
|
||
/// **Round-5 pressure must reach the score** (review #13).
|
||
///
|
||
/// `end_round_events` scored from `self` while H1-A's pressure
|
||
/// went into `work`, so the last round's Stress was invisible to
|
||
/// the GR-E03 and GR-E04 tiebreaks — the two modes CB-EV-0030
|
||
/// reports on.
|
||
///
|
||
/// Uniform pressure preserves an ordering, so the case that bites
|
||
/// is the **clamp**: two seats with equal claims, one at 5 and one
|
||
/// at 4, become equal once both are pushed to 5.
|
||
#[test]
|
||
fn the_last_rounds_pressure_reaches_the_tiebreak() {
|
||
let mut s = setup(3, Variant::H1ProblemStress, 5);
|
||
s.mode = ScoringMode::CommonProblem;
|
||
s.round = 5;
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
|
||
// A CONSTRUCTED board, not the deal: the tiebreak only
|
||
// decides among seats tied at the top, and no natural 3p deal
|
||
// splits evenly while leaving a Problem unclaimed.
|
||
//
|
||
// Two Problems worth 4 (one each to the two seats, so they
|
||
// tie on personal) and one worth 1 left unclaimed, so H1-A
|
||
// fires. Claimed = 8 against a threshold of 7.
|
||
let ids: Vec<u32> = s.problems.keys().copied().collect();
|
||
for id in &ids {
|
||
s.problems.remove(id);
|
||
}
|
||
let mk = |value: u8, claimed_by: Option<PlayerId>| ProblemState {
|
||
suit: Suit::Repair,
|
||
value,
|
||
face_up: true,
|
||
denied: false,
|
||
claimed_by,
|
||
protected_this_round: false,
|
||
owner: None,
|
||
scope: None,
|
||
};
|
||
s.problems.insert(1, mk(4, Some(seats[0])));
|
||
s.problems.insert(2, mk(4, Some(seats[1])));
|
||
s.problems.insert(3, mk(1, None));
|
||
s.players.get_mut(&seats[0]).expect("a").stress = 5;
|
||
s.players.get_mut(&seats[1]).expect("b").stress = 4;
|
||
|
||
let events = s.end_round_events();
|
||
let Some(GroundEvent::GameEnded { outcome }) = events.last() else {
|
||
panic!("round 5 did not end the game");
|
||
};
|
||
assert!(outcome.group_success, "the fixture must qualify");
|
||
|
||
// **The oracle names the expected winners** (CB-REV-0002 #4).
|
||
// The first version asserted only that the set CHANGED, so
|
||
// reversing GR-E03's tiebreak — making HIGHER Stress win —
|
||
// left it green: the set still changed, just wrongly.
|
||
let pre = s.score();
|
||
assert_eq!(
|
||
pre.winners,
|
||
vec![seats[1]],
|
||
"before the pressure, the seat at Stress 4 wins on the tiebreak"
|
||
);
|
||
assert_eq!(
|
||
outcome.winners,
|
||
vec![seats[0], seats[1]],
|
||
"after it both are clamped to 5, so they share"
|
||
);
|
||
}
|
||
|
||
/// **Every tiebreak comparator, both modes** (CB-REV-0003 #4).
|
||
///
|
||
/// The strengthened oracle covered **1 of 6**: GR-E03's Stress
|
||
/// key. Reversing GR-E03's *Bond* key, or **either** of GR-E04's
|
||
/// two keys, left all 179 tests and all 26 scenarios green —
|
||
/// GR-E04's tiebreak had no coverage at all, because the only
|
||
/// GR-E04 scenario sets `group_success: false`, so the winners
|
||
/// branch returns empty and the comparator never runs.
|
||
///
|
||
/// Each case is built so exactly one key decides, and the
|
||
/// expected winner is named — asserting "the set changed" is what
|
||
/// let a reversed comparator pass in the first place.
|
||
#[test]
|
||
fn every_tiebreak_key_decides_the_way_the_rules_say() {
|
||
let board = |mode: ScoringMode| {
|
||
let mut s = setup(4, Variant::Baseline, 5);
|
||
s.mode = mode;
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
let ids: Vec<u32> = s.problems.keys().copied().collect();
|
||
for id in &ids {
|
||
s.problems.remove(id);
|
||
}
|
||
(s, seats)
|
||
};
|
||
let mk = |value: u8, claimed_by: Option<PlayerId>| ProblemState {
|
||
suit: Suit::Repair,
|
||
value,
|
||
face_up: true,
|
||
denied: false,
|
||
claimed_by,
|
||
protected_this_round: false,
|
||
owner: None,
|
||
scope: None,
|
||
};
|
||
|
||
// GR-E03 key 2, Stress: equal claims, lower Stress wins.
|
||
let (mut s, seats) = board(ScoringMode::CommonProblem);
|
||
s.problems.insert(1, mk(4, Some(seats[0])));
|
||
s.problems.insert(2, mk(4, Some(seats[1])));
|
||
s.players.get_mut(&seats[0]).expect("a").stress = 5;
|
||
s.players.get_mut(&seats[1]).expect("b").stress = 1;
|
||
assert_eq!(
|
||
s.score().winners,
|
||
vec![seats[1]],
|
||
"GR-E03: lower Stress must win the tiebreak"
|
||
);
|
||
|
||
// GR-E03 key 3, Bonds: equal claims AND equal Stress, more
|
||
// Bonds wins.
|
||
s.players.get_mut(&seats[0]).expect("a").stress = 1;
|
||
s.relations
|
||
.insert(Pair::new(seats[0], seats[2]), Relation::Bond);
|
||
assert_eq!(
|
||
s.score().winners,
|
||
vec![seats[0]],
|
||
"GR-E03: with claims and Stress level, more Bonds must win"
|
||
);
|
||
|
||
// GR-E04 key 2, combined Stress: two coalitions of equal
|
||
// score, the calmer one wins.
|
||
let (mut s, seats) = board(ScoringMode::BondedCoalitions);
|
||
s.problems.insert(1, mk(4, Some(seats[0])));
|
||
s.problems.insert(2, mk(4, Some(seats[2])));
|
||
s.relations
|
||
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
|
||
s.relations
|
||
.insert(Pair::new(seats[2], seats[3]), Relation::Bond);
|
||
for seat in &seats {
|
||
s.players.get_mut(seat).expect("p").stress = 1;
|
||
}
|
||
s.players.get_mut(&seats[0]).expect("p").stress = 5;
|
||
let won = s.score().winners;
|
||
assert!(
|
||
won.contains(&seats[2]) && !won.contains(&seats[0]),
|
||
"GR-E04: the coalition with lower combined Stress must win, got {won:?}"
|
||
);
|
||
|
||
// GR-E04 key 3, Blame — and reaching it takes care, which is
|
||
// the point. A coalition's score is `sum(claimed - blame)`, so
|
||
// a Blame token lowers the score too and key 1 decides first.
|
||
// The key is only reachable when the claims COMPENSATE: 5
|
||
// claimed with one Blame ties 4 claimed with none.
|
||
s.players.get_mut(&seats[0]).expect("p").stress = 1;
|
||
s.problems.insert(1, mk(5, Some(seats[0])));
|
||
s.problems.insert(2, mk(4, Some(seats[2])));
|
||
s.players.get_mut(&seats[0]).expect("p").blame_from = vec![seats[3]];
|
||
let scored = s.score();
|
||
assert_eq!(
|
||
scored.coalitions.len(),
|
||
2,
|
||
"the fixture needs two coalitions to compare"
|
||
);
|
||
assert_eq!(
|
||
scored.coalitions[0].score, scored.coalitions[1].score,
|
||
"the Blame key is unreachable unless the scores tie: {:?}",
|
||
scored.coalitions
|
||
);
|
||
let won = scored.winners;
|
||
assert!(
|
||
won.contains(&seats[2]) && !won.contains(&seats[0]),
|
||
"GR-E04: with score and Stress level, fewer Blame must win, got {won:?}"
|
||
);
|
||
}
|
||
|
||
/// **`rules_delta.yaml`'s `unchanged:` list is ground-game's claim
|
||
/// about their own experiment, and it is checkable.**
|
||
///
|
||
/// Trusting it would be taking a rules statement on faith, which
|
||
/// is the habit CB-WP-0037 was written to end.
|
||
#[test]
|
||
fn h1_changes_nothing_it_said_it_would_not() {
|
||
for players in [2u8, 3, 4, 5, 6] {
|
||
let base = setup(players, Variant::Baseline, 11);
|
||
let h1 = setup(players, Variant::H1ProblemStress, 11);
|
||
|
||
// deal_and_thresholds
|
||
assert_eq!(base.problems, h1.problems, "{players}p: the deal moved");
|
||
assert_eq!(
|
||
base.threshold(),
|
||
h1.threshold(),
|
||
"{players}p: the threshold moved"
|
||
);
|
||
// start_stress: 2
|
||
for (seat, p) in &h1.players {
|
||
assert_eq!(p.stress, 2, "{players}p {seat}: starting Stress moved");
|
||
}
|
||
// relation_slots: 2 — asserted through the engine's own
|
||
// capacity check rather than a constant beside it.
|
||
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
|
||
assert!(h1.has_free_slot(seats[0]), "a fresh seat has slots");
|
||
// ground_modes / darvo_stage_table / support: the tables
|
||
// are shared code, so equality of the starting state plus
|
||
// the deltas' scope is what carries them.
|
||
assert_eq!(base.mode, h1.mode, "{players}p: scoring mode moved");
|
||
assert_eq!(
|
||
base.solution_deck, h1.solution_deck,
|
||
"{players}p: deck moved"
|
||
);
|
||
|
||
// solve_legality / support / ground_modes: the SAME menu
|
||
// in the SAME position. Comparing setup fields could not
|
||
// see a delta that forbids an action -- the review made
|
||
// SOLVE illegal under H1 and this test stayed green.
|
||
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
|
||
for seat in &seats {
|
||
let mut lb: Vec<String> = crate::bot::legal_commands(&base, *seat)
|
||
.iter()
|
||
.map(|c| format!("{c:?}"))
|
||
.collect();
|
||
let mut lh: Vec<String> = crate::bot::legal_commands(&h1, *seat)
|
||
.iter()
|
||
.map(|c| format!("{c:?}"))
|
||
.collect();
|
||
lb.sort();
|
||
lh.sort();
|
||
assert_eq!(
|
||
lb, lh,
|
||
"{players}p {seat}: H1 changed which commands are legal"
|
||
);
|
||
}
|
||
|
||
// relation_slots: 2, asserted at the BOUNDARY. The old
|
||
// check asked `has_free_slot` of a seat with no relations,
|
||
// which holds for any capacity >= 1.
|
||
if seats.len() >= 3 {
|
||
let mut s = h1.clone();
|
||
s.relations
|
||
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
|
||
assert!(s.has_free_slot(seats[0]), "one relation leaves a slot");
|
||
s.relations
|
||
.insert(Pair::new(seats[0], seats[2]), Relation::Bond);
|
||
assert!(
|
||
!s.has_free_slot(seats[0]),
|
||
"{players}p: two relations must fill both slots -- capacity moved"
|
||
);
|
||
}
|
||
|
||
// darvo_stage_table: the arm sits at Stress 5. Every
|
||
// Problem claimed, so H1-A cannot add pressure and the
|
||
// only question is where the threshold is.
|
||
let mut s = h1.clone();
|
||
for p in s.players.values_mut() {
|
||
p.stress = 4;
|
||
}
|
||
for p in s.problems.values_mut() {
|
||
p.claimed_by = Some(seats[0]);
|
||
}
|
||
assert!(
|
||
!s.end_round_events()
|
||
.iter()
|
||
.any(|e| matches!(e, GroundEvent::DarvoTriggered { .. })),
|
||
"{players}p: DARVO armed below Stress 5 -- the stage table moved"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
config: crate::config::Configuration::default(),
|
||
scenario: default_scenario(),
|
||
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()
|
||
}
|
||
|
||
/// **The mastery rating counts cards; the Mode card counts points**
|
||
/// (F28, CB-WP-0047).
|
||
///
|
||
/// MODE_COOP reads: *"All claimed Problem cards form one shared
|
||
/// score. … For a mastery rating, subtract 1 for each Blame token
|
||
/// still in play and 1 for each Denied Problem."*
|
||
///
|
||
/// The shared score is the claimed **value** — `total`, which is what
|
||
/// the threshold is compared against two lines earlier. `mastery`
|
||
/// subtracts the same penalties from the claimed **count**. Both
|
||
/// readings fit the sentence; they disagree on every game where a
|
||
/// 3-point Problem is claimed, which is most of them.
|
||
///
|
||
/// **A characterisation test, not a correction.** Scoring is
|
||
/// `ground-game`'s to rule on (ADR-0015). This pins what the engine
|
||
/// does today and shows the size of the disagreement, so the ruling
|
||
/// has a number in front of it.
|
||
#[test]
|
||
fn the_mastery_rating_is_in_points_as_the_mode_card_now_says() {
|
||
let mut s = setup_3p(11);
|
||
s.mode = ScoringMode::SharedGround;
|
||
// Two claimed Problems worth 2 and 3 — five points, two cards.
|
||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||
s.problems.insert(
|
||
1,
|
||
ProblemState {
|
||
suit: Suit::Repair,
|
||
value: 2,
|
||
face_up: true,
|
||
denied: false,
|
||
claimed_by: Some(seats[0]),
|
||
protected_this_round: false,
|
||
owner: None,
|
||
scope: None,
|
||
},
|
||
);
|
||
s.problems.insert(
|
||
2,
|
||
ProblemState {
|
||
suit: Suit::Change,
|
||
value: 3,
|
||
face_up: true,
|
||
denied: false,
|
||
claimed_by: Some(seats[1]),
|
||
protected_this_round: false,
|
||
owner: None,
|
||
scope: None,
|
||
},
|
||
);
|
||
let o = s.score();
|
||
assert_eq!(o.total, 5, "the shared score is claimed VALUE");
|
||
assert_eq!(
|
||
o.mastery,
|
||
Some(5),
|
||
"mastery starts from the shared POINT total, not the card count"
|
||
);
|
||
// The board is chosen so the two readings disagree — two cards
|
||
// worth five points. Without that the test would pass under the
|
||
// reading it exists to exclude.
|
||
assert_eq!(
|
||
s.problems
|
||
.values()
|
||
.filter(|p| p.claimed_by.is_some())
|
||
.count(),
|
||
2,
|
||
"the fixture must claim a number of CARDS different from the \
|
||
POINT total, or it cannot tell the two readings apart"
|
||
);
|
||
|
||
// **The edition's own sentence, not our memory of the ruling.**
|
||
// `Modes.csv` was clarified upstream; if that clarification is
|
||
// ever reverted this goes red rather than quietly diverging.
|
||
let (card, _) = crate::edition::modes()
|
||
.expect("Modes.csv")
|
||
.into_iter()
|
||
.find(|(c, _)| c.id == "MODE_COOP")
|
||
.expect("MODE_COOP");
|
||
assert!(
|
||
card.rules_text.contains("not card count"),
|
||
"the Mode card no longer rules out the card-count reading: {:?}",
|
||
card.rules_text
|
||
);
|
||
|
||
// And the penalties still bite, in points.
|
||
let seat = seats[0];
|
||
s.players.get_mut(&seat).unwrap().blame_from = vec![seats[1]];
|
||
assert_eq!(s.score().mastery, Some(4), "a Blame token costs 1 POINT");
|
||
}
|
||
|
||
/// **Every scenario is reachable through `setup`** (CB-WP-0047).
|
||
///
|
||
/// `setup` passed the literal `"SCN_01"`. `deal` had taken a
|
||
/// scenario id since the day it was written and no caller ever
|
||
/// passed a different one — the parameter was the whole seam and it
|
||
/// sat unused, which is why three decks went unplayed without any
|
||
/// gate noticing.
|
||
#[test]
|
||
fn every_scenario_can_be_set_up_and_carries_its_own_board() {
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
let mut boards = std::collections::BTreeSet::new();
|
||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||
let preset = if s.id == "SCN_01" {
|
||
"standard-4p".to_string()
|
||
} else {
|
||
format!("scn-{}-4p", s.id.rsplit('_').next().unwrap())
|
||
};
|
||
let state = GroundState::setup(
|
||
&Setup {
|
||
players: 4,
|
||
preset: preset.clone(),
|
||
patch: Default::default(),
|
||
},
|
||
7,
|
||
)
|
||
.unwrap_or_else(|e| panic!("{preset}: {e}"));
|
||
assert_eq!(state.scenario, s.id, "{preset} dealt the wrong scenario");
|
||
boards.insert(
|
||
state
|
||
.problems
|
||
.values()
|
||
.map(|p| (format!("{:?}", p.suit), p.value))
|
||
.collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
// Four scenarios, four boards (F29). This asserted THREE until
|
||
// 2026-08-08, because SCN_01 and SCN_02 were identical decks;
|
||
// ground-game ruled that unintended and re-tuned SCN_02.
|
||
assert_eq!(
|
||
boards.len(),
|
||
4,
|
||
"the four scenarios yield {} distinct boards at 4p",
|
||
boards.len()
|
||
);
|
||
}
|
||
|
||
/// The preset grammar, including what it must keep meaning.
|
||
#[test]
|
||
fn the_preset_names_a_scenario_and_a_seat_count() {
|
||
assert_eq!(parse_preset("standard-4p", 4).unwrap(), "SCN_01");
|
||
assert_eq!(parse_preset("scn-03-6p", 6).unwrap(), "SCN_03");
|
||
// Twenty-six recordings name `standard-Np`; if this ever stopped
|
||
// meaning SCN_01 every one of them would replay a different board
|
||
// while its hash still claimed to pin the old one.
|
||
assert_eq!(parse_preset("standard-2p", 2).unwrap(), "SCN_01");
|
||
// A preset that looks like an id but names no card is refused
|
||
// HERE, so the error is about the preset and not about Problems.
|
||
assert!(parse_preset("scn-09-4p", 4).is_err());
|
||
assert!(parse_preset("standard-4p", 3).is_err());
|
||
assert!(parse_preset("nonsense-4p", 4).is_err());
|
||
}
|
||
|
||
/// **The threshold comes off the Scenario card** (CB-WP-0047, F25).
|
||
#[test]
|
||
fn the_threshold_is_the_editions_and_the_fallback_is_unreachable() {
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||
for (players, want) in [
|
||
(2u8, s.thresholds.0),
|
||
(4, s.thresholds.1),
|
||
(6, s.thresholds.2),
|
||
] {
|
||
let preset = if s.id == "SCN_01" {
|
||
format!("standard-{players}p")
|
||
} else {
|
||
format!("scn-{}-{players}p", s.id.rsplit('_').next().unwrap())
|
||
};
|
||
let state = GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset,
|
||
patch: Default::default(),
|
||
},
|
||
3,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
state.threshold(),
|
||
want,
|
||
"{} at {players}p: the engine scored against its own number",
|
||
s.id
|
||
);
|
||
}
|
||
}
|
||
// **The real control.** Every shipped scenario prints 5/7/9, so
|
||
// the loop above passes whether the number came off the card or
|
||
// off the old hardcoded bands — verified by mutation: reverting
|
||
// to the bands left it green. This asks a card that DISAGREES.
|
||
let odd = crate::edition::ScenarioText {
|
||
id: "SCN_XX".into(),
|
||
title: "a card that disagrees".into(),
|
||
premise: String::new(),
|
||
surface_problem_id: String::new(),
|
||
hidden_problem_ids: vec![],
|
||
thresholds: (4, 6, 8),
|
||
starting_stress: String::new(),
|
||
round_track: String::new(),
|
||
};
|
||
for (seats, want) in [(2usize, 4), (4, 6), (6, 8)] {
|
||
assert_eq!(
|
||
threshold_from(std::slice::from_ref(&odd), "SCN_XX", seats),
|
||
want,
|
||
"the threshold did not come off the Scenario card"
|
||
);
|
||
}
|
||
// And the fallback is what answers for a card the edition lacks.
|
||
assert_eq!(threshold_from(&[], "SCN_XX", 4), 7);
|
||
|
||
// That fallback is unreachable through `setup`, which refuses an
|
||
// unknown scenario before dealing. A fallback nothing can reach
|
||
// is the only kind that cannot silently answer for the real one.
|
||
assert!(GroundState::setup(
|
||
&Setup {
|
||
players: 4,
|
||
preset: "scn-09-4p".into(),
|
||
patch: Default::default(),
|
||
},
|
||
3
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
/// 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 as ruled 2026-08-04: Surface always, plus hidden
|
||
// priorities 1..=k. At 3 players k = 3, so FOUR Problems are
|
||
// dealt — Surface face up, the three hidden ones face down. The
|
||
// engine used to deal Surface + (k-1), which is what made the
|
||
// group unable to reach GR-E01's threshold.
|
||
assert_eq!(
|
||
state.problems.len(),
|
||
4,
|
||
"GR-S01 deals Surface + hidden 1..=3"
|
||
);
|
||
assert!(state.problems[&1].face_up, "the Surface Problem is face up");
|
||
for slot in 2..=4 {
|
||
assert!(
|
||
!state.problems[&slot].face_up,
|
||
"hidden Problem {slot} is face up"
|
||
);
|
||
}
|
||
// The edition's values, not the stand-in's `value = priority`.
|
||
let dealt: Vec<u8> = (1..=4).map(|n| state.problems[&n].value).collect();
|
||
assert_eq!(dealt, vec![2, 2, 2, 3], "edition point_value not loaded");
|
||
}
|
||
|
||
/// 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::{ScenarioFile, ScenarioGame};
|
||
|
||
/// 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() {
|
||
// K18 / single source of fact (InnerLoop v1.3): the workload is
|
||
// read from the SAME file the Criterion bench replays. It used to
|
||
// be hardcoded here as well, so the round existed twice and the
|
||
// two copies could drift — editing the YAML broke `bench-test`
|
||
// while this test kept passing.
|
||
let yaml = include_str!("../../../benchmarks/synthetic-3p.yaml");
|
||
let sc = ScenarioFile::from_yaml(yaml).expect("bench workload parses");
|
||
let mut state = GroundState::setup(&sc.setup, sc.seed).unwrap();
|
||
|
||
let mut events = 0;
|
||
let mut commands = 0;
|
||
for step in &sc.commands {
|
||
let (actor, cmd) = GroundState::parse_command(step).expect("workload command");
|
||
commands += 1;
|
||
if let Ok(produced) = state.validate(actor, &cmd) {
|
||
for e in &produced {
|
||
state.fold(e);
|
||
}
|
||
events += produced.len();
|
||
}
|
||
}
|
||
|
||
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, Snapshot};
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
use cb_kernel::EventSeq;
|
||
use std::time::Instant;
|
||
|
||
/// A fresh game at `seats` players, for the seat-count sweep.
|
||
fn fresh_n(seats: u8) -> GroundState {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players: seats,
|
||
preset: format!("standard-{seats}p"),
|
||
patch: BTreeMap::new(),
|
||
},
|
||
42,
|
||
)
|
||
.expect("a standard deal")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/// K9: `snapshot + remaining events -> state` must be hash-identical to
|
||
/// a from-genesis fold.
|
||
///
|
||
/// **This is the assertion K9 did not have.** Its entire evidence was
|
||
/// one test round-tripping a `BTreeMap<String, u8>` with `EventSeq(17)`
|
||
/// as a literal — no game aggregate, no events applied, no
|
||
/// from-genesis comparison. CB-WP-0005 proved it inert by mutation:
|
||
/// making `Snapshot::take` discard its `EventSeq` and store 0 left the
|
||
/// test green, so the half of K9 that says "**+ the EventId it
|
||
/// includes**" was unverified.
|
||
///
|
||
/// Single-seed on purpose. AM-7's probe folds a log built across games
|
||
/// seeded 42, 43, 44... into a state from `fresh(42)`, which is not a
|
||
/// replay of anything; that defect is not repeated here.
|
||
#[test]
|
||
fn k9_snapshot_plus_remaining_events_equals_genesis_fold() {
|
||
let mut source = fresh(42);
|
||
let mut log = Vec::new();
|
||
while log.len() < 400 && source.outcome.is_none() {
|
||
if record_round(&mut source, &mut log) == 0 {
|
||
break;
|
||
}
|
||
}
|
||
// Positive control: a trivial log would make the comparison pass
|
||
// for the wrong reason.
|
||
assert!(
|
||
log.len() >= 50,
|
||
"K9 needs a non-trivial single-game log, got {} events",
|
||
log.len()
|
||
);
|
||
|
||
let mut genesis = fresh(42);
|
||
for e in &log {
|
||
genesis.fold(e);
|
||
}
|
||
let genesis_hash = state_hash_hex(&genesis);
|
||
|
||
let n = log.len() / 2;
|
||
let mut mid = fresh(42);
|
||
for e in &log[..n] {
|
||
mid.fold(e);
|
||
}
|
||
let snap = Snapshot::take(&mid, EventSeq(n as u64));
|
||
|
||
// The clause the mutation exposed: a snapshot is the aggregate
|
||
// **plus the EventId it includes**. Without this, `take` could
|
||
// discard `through` entirely and nothing would notice.
|
||
assert_eq!(
|
||
snap.through,
|
||
EventSeq(n as u64),
|
||
"K9: the snapshot must carry the EventSeq it includes"
|
||
);
|
||
|
||
let mut restored: GroundState = snap.restore().unwrap();
|
||
// And the snapshot must not already equal the end state, or
|
||
// "apply the remainder" would be vacuous.
|
||
assert_ne!(
|
||
state_hash_hex(&restored),
|
||
genesis_hash,
|
||
"K9: the mid-log snapshot must differ from the end state"
|
||
);
|
||
for e in &log[n..] {
|
||
restored.fold(e);
|
||
}
|
||
|
||
assert_eq!(
|
||
state_hash_hex(&restored),
|
||
genesis_hash,
|
||
"K9 UNMET: snapshot at seq {n} + {} remaining events did not \
|
||
reproduce the from-genesis fold over {} events",
|
||
log.len() - n,
|
||
log.len()
|
||
);
|
||
}
|
||
|
||
/// AM-6 target from GameKernel §5, in applied events per second.
|
||
///
|
||
/// **Pinned, not tuned.** CB-WP-0006 T01 named the trap up front: a
|
||
/// timing assertion is flaky by nature and the reflex is to loosen it
|
||
/// until it never fires, which reproduces the defect being fixed —
|
||
/// this row was `unmutatable` because *nothing in the workspace
|
||
/// compared any number to 100,000*, while the evidence file reported
|
||
/// `AM-6 | met, 16.5×`.
|
||
///
|
||
/// Measured on bnt-lap001 2026-07-31: **~182k–212k ev/s in debug**,
|
||
/// **~2.4M–3.1M ev/s in release**. So the spec target holds even in an
|
||
/// unoptimized build, with ~1.8× headroom there and ~24× in release.
|
||
/// **Lowering this constant requires an ADR.**
|
||
const AM6_EVENTS_PER_SEC: f64 = 100_000.0;
|
||
|
||
/// Best of N samples. A throughput *floor* asks "is this machine
|
||
/// capable", so transient load should not fail the build; taking the
|
||
/// max makes the gate robust without loosening the threshold, which is
|
||
/// the trade this task was told to avoid making on the threshold.
|
||
const AM6_SAMPLES: usize = 3;
|
||
|
||
/// AM-6: applied events/s on the synthetic workload must clear the
|
||
/// spec target. A test, not a bench — Criterion reports throughput and
|
||
/// asserts nothing, which is why this row measured nothing for six
|
||
/// passes.
|
||
///
|
||
/// **`#[ignore]` on purpose, and this is the T04 correction.** The
|
||
/// assertion first ran inside `make all` and failed at 38,753 ev/s
|
||
/// against 341,280 measured in isolation — a 9x drop, because
|
||
/// `cargo test` runs test binaries and threads **concurrently**. A
|
||
/// throughput assertion inside a parallel harness measures contention,
|
||
/// not throughput. The fix is not a lower target (T01 forbade that,
|
||
/// and it would reproduce the defect being fixed) but a measurement
|
||
/// that only runs where it is valid: `make am6`, release,
|
||
/// `--test-threads=1`.
|
||
#[test]
|
||
#[ignore = "throughput measurement — invalid under a parallel harness; run `make am6`"]
|
||
fn am6_throughput_clears_the_spec_target() {
|
||
let mut best = 0.0f64;
|
||
let mut sampled = 0usize;
|
||
for s in 0..AM6_SAMPLES {
|
||
let mut state = fresh(7 + s as u64);
|
||
let mut log = Vec::new();
|
||
let mut n = 0usize;
|
||
let t = Instant::now();
|
||
while n < 50_000 {
|
||
if state.outcome.is_some() {
|
||
state = fresh(7 + (s * 1_000_000 + n) as u64);
|
||
}
|
||
n += record_round(&mut state, &mut log);
|
||
log.clear();
|
||
}
|
||
let secs = t.elapsed().as_secs_f64();
|
||
// Positive control: a run that applied no events, or took no
|
||
// measurable time, must not be scored as infinite throughput.
|
||
assert!(
|
||
n >= 50_000,
|
||
"AM-6 harness applied {n} events, expected >= 50000"
|
||
);
|
||
assert!(secs > 0.0, "AM-6 harness measured zero elapsed time");
|
||
best = best.max(n as f64 / secs);
|
||
sampled += n;
|
||
}
|
||
let headroom = best / AM6_EVENTS_PER_SEC;
|
||
println!(
|
||
"AM-6: {best:.0} events/s (best of {AM6_SAMPLES}, {sampled} events, \
|
||
debug_assertions={}) — {headroom:.1}x the {AM6_EVENTS_PER_SEC:.0} target",
|
||
cfg!(debug_assertions)
|
||
);
|
||
assert!(
|
||
best >= AM6_EVENTS_PER_SEC,
|
||
"AM-6 UNMET: {best:.0} events/s < {AM6_EVENTS_PER_SEC:.0} \
|
||
({headroom:.2}x). Reference: ~1.7M via `make am6` on \
|
||
bnt-lap001. Do NOT lower the target to pass — GameKernel §5 \
|
||
AM-6 is a spec value and lowering it needs an ADR. If this \
|
||
fired under a parallel harness, the measurement is invalid \
|
||
rather than the target: run `make am6`.",
|
||
);
|
||
}
|
||
|
||
/// 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);
|
||
// Per-game segments with the hash of the state that produced
|
||
// them. AM-7's `hash-identical` clause was withdrawn because
|
||
// the fold ran a multi-seed log into a single genesis state
|
||
// and then never compared the hash to anything. Segments make
|
||
// the comparison meaningful: each is a real replay.
|
||
let mut segments: Vec<(u64, usize, String)> = Vec::new();
|
||
let mut seed = 42u64;
|
||
let mut source = fresh(seed);
|
||
let mut seg_start = 0usize;
|
||
let mut stalls = 0;
|
||
while log.len() < target {
|
||
if source.outcome.is_some() {
|
||
segments.push((seed, seg_start, state_hash_hex(&source)));
|
||
seg_start = log.len();
|
||
seed += 1;
|
||
source = fresh(seed);
|
||
}
|
||
if record_round(&mut source, &mut log) == 0 {
|
||
stalls += 1;
|
||
assert!(stalls < 10, "round produced no events; builder stalled");
|
||
}
|
||
}
|
||
segments.push((seed, seg_start, state_hash_hex(&source)));
|
||
|
||
// AM-7 hash-identical, re-earned (CB-WP-0006 T06). Replay each
|
||
// segment from its own genesis and require the recorded hash.
|
||
let mut ends: Vec<usize> = segments.iter().skip(1).map(|s| s.1).collect();
|
||
ends.push(log.len());
|
||
let mut verified = 0usize;
|
||
for ((seg_seed, start, want), end) in segments.iter().zip(ends) {
|
||
let mut st = fresh(*seg_seed);
|
||
for e in &log[*start..end] {
|
||
st.fold(e);
|
||
}
|
||
assert_eq!(
|
||
&state_hash_hex(&st),
|
||
want,
|
||
"AM-7 hash-identical UNMET: replaying segment seeded \
|
||
{seg_seed} ({start}..{end}) did not reproduce its \
|
||
recorded state hash"
|
||
);
|
||
verified += 1;
|
||
}
|
||
// Positive control: verifying zero segments would pass vacuously.
|
||
assert!(
|
||
verified >= 2,
|
||
"AM-7 needs several segments to verify, got {verified}"
|
||
);
|
||
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");
|
||
}
|
||
}
|
||
|
||
/// **GD-0001, INVERTED 2026-08-04.** Group success is reachable at
|
||
/// every seat count.
|
||
///
|
||
/// This test used to assert the opposite, and it was right to: the
|
||
/// maintainer played several 3-player games on 2026-08-03 and could
|
||
/// not win any of them, because GR-S01 dealt 2/3/4 Problems worth
|
||
/// 3/6/10 against thresholds of 5/7/9.
|
||
///
|
||
/// ground-game ruled the deal on 2026-08-04 — **Surface always, plus
|
||
/// hidden priorities 1..=k** — which with this edition's values gives
|
||
/// **6 / 9 / 12**. The ruling said to invert this test rather than
|
||
/// retire it, and that is why it is still here: a reader learns the
|
||
/// game *became* winnable, not that a test quietly vanished.
|
||
///
|
||
/// It reads both numbers out of the engine — the deal and
|
||
/// `threshold` — so it cannot drift from the rules it tests.
|
||
///
|
||
/// **The 2p case is the one to watch.** 2+2+2 against a threshold of
|
||
/// 5 means a full clear: any two Problems sum to 4. Reachable is not
|
||
/// forgiving, and ground-game kept that deliberately.
|
||
#[test]
|
||
fn gd0001_group_success_is_reachable_at_every_seat_count() {
|
||
let mut verdicts = Vec::new();
|
||
for seats in 2..=6u8 {
|
||
let state = fresh_n(seats);
|
||
let best: u32 = state.problems.values().map(|p| u32::from(p.value)).sum();
|
||
let need = state.threshold();
|
||
verdicts.push((seats, state.problems.len(), best, need, best >= need));
|
||
println!(
|
||
" {seats}p: {} problem(s) worth {best} against a threshold of {need} \u{2014} {}",
|
||
state.problems.len(),
|
||
if best >= need {
|
||
"reachable"
|
||
} else {
|
||
"UNREACHABLE"
|
||
}
|
||
);
|
||
}
|
||
let unreachable: Vec<u8> = verdicts
|
||
.iter()
|
||
.filter(|(_, _, _, _, ok)| !ok)
|
||
.map(|(s, _, _, _, _)| *s)
|
||
.collect();
|
||
assert!(
|
||
unreachable.is_empty(),
|
||
"group success is unreachable at {unreachable:?} seats — the \
|
||
ruled deal (Surface + hidden 1..=k) is not what the engine \
|
||
deals, or the edition values changed"
|
||
);
|
||
// The ruled numbers, asserted rather than implied: 6/9/12 against
|
||
// 5/7/9. A deal that was reachable for the wrong reason — more
|
||
// Problems, or richer ones — would pass the check above.
|
||
let available: Vec<u32> = verdicts.iter().map(|(_, _, b, _, _)| *b).collect();
|
||
assert_eq!(
|
||
available,
|
||
vec![6, 9, 9, 12, 12],
|
||
"available points are not the 6/9/12 ground-game ruled against"
|
||
);
|
||
// Positive control: a harness that measured nothing would report
|
||
// an empty `unreachable` and pass.
|
||
assert_eq!(verdicts.len(), 5, "the sweep did not cover 2..=6 seats");
|
||
}
|
||
|
||
/// AM-7 scaling floor from GameKernel §5: fold throughput at 100k
|
||
/// events must be at least this fraction of throughput at 5k.
|
||
///
|
||
/// **Pinned, not tuned** — same rule as `AM6_EVENTS_PER_SEC`. The
|
||
/// baseline it was written against is boardgame.io at 0.45–0.66×,
|
||
/// degrading to DNF at 100k. Lowering it requires an ADR.
|
||
const AM7_SCALING_FLOOR: f64 = 0.9;
|
||
|
||
/// The window that is timed, and how deep the late one sits.
|
||
/// **Both timed windows are the same size** — that is the correction
|
||
/// (CB-WP-0021 T06); see `paired_ratio`.
|
||
const AM7_WINDOW: usize = 5_000;
|
||
const AM7_DEPTH: usize = 100_000;
|
||
|
||
/// Events applied **per leg** per sample.
|
||
///
|
||
/// Sized against the machine's drift, not against timer resolution.
|
||
/// At 2,000,000 a sample took ~50 ms, and this machine's throughput
|
||
/// wanders by 2.5× over a few seconds (CB-EV-0013 §1) — so a 50 ms
|
||
/// sample measures whatever the clock happened to be doing. At 10 M
|
||
/// each leg runs ~0.35 s and averages over the drift instead of
|
||
/// sampling a point on it. Measured: medians 0.987 / 0.991 / 0.989
|
||
/// across three runs, and 0.989 under 8-way CPU contention. Doubling
|
||
/// this to 20 M cost 9 s more per run and did not tighten them.
|
||
const AM7_EVENTS_PER_LEG: usize = 10_000_000;
|
||
|
||
/// Paired samples per run. Nine rather than AM-6's three because the
|
||
/// verdict is a **median**, not a best-of: a median needs enough
|
||
/// samples that one excursion cannot move it.
|
||
const AM7_SAMPLES: usize = 9;
|
||
|
||
/// Fraction of samples that must agree with the median's verdict for
|
||
/// the run to be a measurement rather than noise.
|
||
///
|
||
/// **This replaced a unanimity guard that was measurably wrong.** The
|
||
/// first version declared INDETERMINATE whenever any sample fell on
|
||
/// the other side of the floor. Under deliberate 8-way CPU contention
|
||
/// the ratio held at a median of 0.971 — the pairing works, and
|
||
/// absolute throughput had dropped 4× — but one sample read 0.899,
|
||
/// a thousandth under the floor, and the guard turned a good
|
||
/// measurement into a failed build. It also fired intermittently
|
||
/// inside `mutation-check`, where this test runs straight after a
|
||
/// 50-second rebuild.
|
||
///
|
||
/// A gate that fails when the machine is busy is a flake, and a flake
|
||
/// gets suppressed rather than fixed. Requiring a two-thirds majority
|
||
/// keeps the guard's purpose — refusing to read a coin-flip as a
|
||
/// verdict — without treating a single outlier as one.
|
||
const AM7_AGREEMENT: f64 = 2.0 / 3.0;
|
||
|
||
/// Fold `n` events from `log` starting at `from`, on a state already
|
||
/// advanced to `from`, returning only the time inside the fold loop.
|
||
/// The state after folding `log[..depth]` — the history the window
|
||
/// will be folded on top of.
|
||
///
|
||
/// Built **once per sample**, not once per repetition. The first
|
||
/// version re-walked the prefix every rep: 2,000 reps x 100,000
|
||
/// events is 200M untimed folds per sample, and under the
|
||
/// history-proportional mutation that is quadratic and never
|
||
/// finishes. A control that cannot be run is not a control.
|
||
fn state_at(log: &[GroundEvent], depth: usize) -> GroundState {
|
||
let mut state = fresh(42);
|
||
for e in &log[..depth] {
|
||
state.fold(e);
|
||
}
|
||
state
|
||
}
|
||
|
||
/// Fold `n` events from `at` onto a clone of `state`, returning only
|
||
/// the time inside the fold loop. The clone is outside the clock.
|
||
fn fold_window(
|
||
state: &GroundState,
|
||
log: &[GroundEvent],
|
||
at: usize,
|
||
n: usize,
|
||
) -> std::time::Duration {
|
||
let mut st = state.clone();
|
||
let t = Instant::now();
|
||
for e in &log[at..at + n] {
|
||
st.fold(e);
|
||
}
|
||
let dt = t.elapsed();
|
||
std::hint::black_box(&st);
|
||
dt
|
||
}
|
||
|
||
/// One paired sample: the SAME window size at two history depths.
|
||
///
|
||
/// **The corrected measurement (CB-WP-0021 T06).** The previous one
|
||
/// folded a 5,000-event log and a 100,000-event log and compared
|
||
/// their throughputs, which confounds two different things:
|
||
///
|
||
/// 1. does cost per event grow with how many events have already
|
||
/// been folded? — the property AM-7 claims; and
|
||
/// 2. does streaming a 20x longer `Vec` cost more per element? — a
|
||
/// memory-hierarchy fact true of any program.
|
||
///
|
||
/// It measured (2) and reported it as (1). Importing the edition data
|
||
/// enlarged the aggregate — four Problems instead of three, real
|
||
/// values — and the ratio fell 0.97 -> 0.845 against a 0.9 floor
|
||
/// **with the state provably bounded**: identical deck, discard,
|
||
/// Problem and hand sizes after 5k and 100k events. A row that fails
|
||
/// because the game got bigger, while the property it names is
|
||
/// untouched, is measuring the wrong thing.
|
||
///
|
||
/// So: time a 5,000-event window at depth 0, and the same-sized window
|
||
/// at depth 100,000. Equal windows mean equal streaming cost, and the
|
||
/// only difference left is history depth — which is the claim.
|
||
fn paired_ratio(log: &[GroundEvent]) -> (f64, f64, f64) {
|
||
let reps = AM7_EVENTS_PER_LEG.div_ceil(AM7_WINDOW);
|
||
let early = state_at(log, 0);
|
||
let late = state_at(log, AM7_DEPTH);
|
||
let (mut t_early, mut t_late) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
||
for _ in 0..reps {
|
||
// Interleaved, so this machine's 2.5x drift is common-mode
|
||
// and divides out (CB-EV-0013 section 1).
|
||
// THE SAME EVENTS on both legs. Timing log[0..W] against
|
||
// log[DEPTH..DEPTH+W] compared two different event mixes and
|
||
// read 0.573 on code whose state is provably bounded — a
|
||
// second confound, introduced while removing the first.
|
||
// Identical events mean the only difference left is how much
|
||
// history the state carries, which is the claim.
|
||
t_early += fold_window(&early, log, 0, AM7_WINDOW);
|
||
t_late += fold_window(&late, log, 0, AM7_WINDOW);
|
||
}
|
||
assert!(
|
||
t_early.as_secs_f64() > 0.0 && t_late.as_secs_f64() > 0.0,
|
||
"AM-7 measured zero elapsed time"
|
||
);
|
||
let n = (reps * AM7_WINDOW) as f64;
|
||
let tp_early = n / t_early.as_secs_f64();
|
||
let tp_late = n / t_late.as_secs_f64();
|
||
(tp_early, tp_late, tp_late / tp_early)
|
||
}
|
||
|
||
/// Build one growing log of at least `target` events, the same way
|
||
/// `replay_100k_events_is_linear_and_fast` does.
|
||
fn growing_log(target: usize) -> Vec<GroundEvent> {
|
||
let mut log = Vec::with_capacity(target);
|
||
let mut seed = 42u64;
|
||
let mut source = fresh(seed);
|
||
let mut stalls = 0;
|
||
while log.len() < target {
|
||
if source.outcome.is_some() {
|
||
seed += 1;
|
||
source = fresh(seed);
|
||
}
|
||
if record_round(&mut source, &mut log) == 0 {
|
||
stalls += 1;
|
||
assert!(stalls < 10, "round produced no events; builder stalled");
|
||
}
|
||
}
|
||
log
|
||
}
|
||
|
||
/// AM-7's `scaling >= 0.9x` clause, which was inert for nine passes.
|
||
///
|
||
/// `mutation-check.py` said it plainly every run: *"no code computes
|
||
/// the ratio of throughput @100k to @5k or compares it to 0.9;
|
||
/// Criterion reports both and nothing relates them."* The sibling test
|
||
/// above is even named `replay_100k_events_is_linear_and_fast` and
|
||
/// checks the two sizes **independently** — it computes both numbers,
|
||
/// prints both, and never divides one by the other.
|
||
///
|
||
/// **`#[ignore]` for the same reason AM-6 is** (CB-WP-0006 T04): a
|
||
/// throughput assertion inside a parallel `cargo test` harness
|
||
/// measures contention. A *ratio* of two such timings is worse, not
|
||
/// better — the noise multiplies rather than cancels. Run `make am7`.
|
||
#[test]
|
||
#[ignore = "throughput ratio — invalid under a parallel harness; run `make am7`"]
|
||
fn am7_cost_per_event_does_not_grow_with_history() {
|
||
let log = growing_log(AM7_DEPTH + AM7_WINDOW);
|
||
|
||
// Positive control on the shape of the measurement. The windows
|
||
// must be the same size — that is the correction — and the late
|
||
// one must actually sit deep in the log. A harness that measured
|
||
// depth 0 twice would report ~1.0 and look excellent.
|
||
assert!(
|
||
log.len() >= AM7_DEPTH + AM7_WINDOW,
|
||
"log is {} events, too short for a window at depth {AM7_DEPTH}",
|
||
log.len()
|
||
);
|
||
const { assert!(AM7_DEPTH >= 15 * AM7_WINDOW) };
|
||
|
||
let mut ratios = Vec::with_capacity(AM7_SAMPLES);
|
||
for _ in 0..AM7_SAMPLES {
|
||
let (tp_early, tp_late, ratio) = paired_ratio(&log);
|
||
println!(
|
||
" AM-7 sample: {tp_early:.0} ev/s at depth 0 → \
|
||
{tp_late:.0} ev/s at depth {AM7_DEPTH} = {ratio:.3}x"
|
||
);
|
||
ratios.push(ratio);
|
||
}
|
||
ratios.sort_by(|a, b| a.partial_cmp(b).expect("no NaN ratios"));
|
||
let (worst, median, best) = (
|
||
ratios[0],
|
||
ratios[ratios.len() / 2],
|
||
ratios[ratios.len() - 1],
|
||
);
|
||
println!(
|
||
"AM-7 scaling: {worst:.3}x / {median:.3}x / {best:.3}x \
|
||
(worst/median/best of {AM7_SAMPLES}, floor {AM7_SCALING_FLOOR})"
|
||
);
|
||
|
||
// The spread is reported, not hidden behind a best-of. The verdict
|
||
// is the median, and it counts as a measurement only if a
|
||
// two-thirds majority of samples agree with it — see
|
||
// AM7_AGREEMENT for the unanimity guard this replaced and why.
|
||
let agreeing = ratios
|
||
.iter()
|
||
.filter(|r| (**r >= AM7_SCALING_FLOOR) == (median >= AM7_SCALING_FLOOR))
|
||
.count();
|
||
let agreement = agreeing as f64 / ratios.len() as f64;
|
||
assert!(
|
||
agreement >= AM7_AGREEMENT,
|
||
"AM-7 INDETERMINATE: only {agreeing} of {} samples agree with \
|
||
the median ({worst:.3}x..{best:.3}x around \
|
||
{AM7_SCALING_FLOOR}). The machine is too noisy for this to be \
|
||
a measurement; do not read the best sample as a pass.",
|
||
ratios.len()
|
||
);
|
||
assert!(
|
||
median >= AM7_SCALING_FLOOR,
|
||
"AM-7 UNMET: folding a {AM7_WINDOW}-event window at history \
|
||
depth {AM7_DEPTH} runs at {median:.3}x the same window at \
|
||
depth 0 (worst {worst:.3}x, best {best:.3}x), below the \
|
||
{AM7_SCALING_FLOOR} floor. Cost per event is growing with \
|
||
history — check whether something in the aggregate grows \
|
||
without bound. Baseline: boardgame.io 0.45-0.66x, DNF at \
|
||
100k. Do NOT lower the floor to pass — GameKernel §5 AM-7 is \
|
||
a spec value and lowering it needs an ADR."
|
||
);
|
||
}
|
||
}
|