2026-07-31 01:57:13 +02:00
|
|
|
|
//! games-ground — the GROUND rules aggregate (specs/GroundRules.md,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
|
|
|
|
|
//! comment, giving a greppable rule→code→scenario chain.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
|
|
|
|
|
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
2026-07-31 01:57:13 +02:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O02: per-player state.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct PlayerState {
|
|
|
|
|
|
/// GR-F01: clamped 0–5.
|
|
|
|
|
|
pub stress: u8,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-F03: the Freedom token is READY until spent.
|
2026-07-31 01:57:13 +02:00
|
|
|
|
pub freedom_ready: bool,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-R03: set when Freedom is spent this round, lifting the stress
|
|
|
|
|
|
/// gate for this Select step only. Cleared at round End.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub freedom_gate_lifted: bool,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// GR-D01/D02: OFF or the pending/active stage.
|
|
|
|
|
|
pub darvo: DarvoStage,
|
|
|
|
|
|
pub hand: Vec<SolutionCard>,
|
|
|
|
|
|
pub protection: u8,
|
|
|
|
|
|
/// Blame tokens in front of this player (GR-T02), keyed by owner.
|
|
|
|
|
|
pub blame_from: Vec<PlayerId>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum DarvoStage {
|
|
|
|
|
|
Off,
|
|
|
|
|
|
Deny,
|
|
|
|
|
|
Attack,
|
|
|
|
|
|
Reverse,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O04: one Problem card's live state.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct ProblemState {
|
|
|
|
|
|
pub suit: Suit,
|
|
|
|
|
|
pub value: u8,
|
|
|
|
|
|
pub face_up: bool,
|
|
|
|
|
|
pub denied: bool,
|
|
|
|
|
|
pub claimed_by: Option<PlayerId>,
|
|
|
|
|
|
/// GR-A11: protected from Deny this round by GROUND—OU.
|
|
|
|
|
|
pub protected_this_round: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum Suit {
|
|
|
|
|
|
Clarify,
|
|
|
|
|
|
Repair,
|
|
|
|
|
|
Boundary,
|
|
|
|
|
|
Change,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct SolutionCard {
|
|
|
|
|
|
pub suit: Suit,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-O05: at most one relation per pair; endpoints ordered low→high.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum Relation {
|
|
|
|
|
|
Bond,
|
|
|
|
|
|
Rivalry,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-O05: an unordered player pair, canonically ordered low→high.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Serialized as `"a-b"` rather than as a tuple: canonical form is JSON
|
|
|
|
|
|
/// (GameKernel K7), and JSON object keys must be strings — a tuple key
|
|
|
|
|
|
/// makes `state_hash` fail on any state that holds a relation.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
|
|
|
|
pub struct Pair(pub PlayerId, pub PlayerId);
|
|
|
|
|
|
|
|
|
|
|
|
impl Pair {
|
|
|
|
|
|
pub fn new(a: PlayerId, b: PlayerId) -> Self {
|
|
|
|
|
|
if a <= b {
|
|
|
|
|
|
Pair(a, b)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Pair(b, a)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn contains(&self, player: PlayerId) -> bool {
|
|
|
|
|
|
self.0 == player || self.1 == player
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl core::fmt::Display for Pair {
|
|
|
|
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
|
|
|
|
write!(f, "{}-{}", self.0 .0, self.1 .0)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Serialize for Pair {
|
|
|
|
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
|
|
|
|
serializer.collect_str(self)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl<'de> Deserialize<'de> for Pair {
|
|
|
|
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
|
|
|
|
let raw = String::deserialize(deserializer)?;
|
|
|
|
|
|
let (a, b) = raw
|
|
|
|
|
|
.split_once('-')
|
|
|
|
|
|
.ok_or_else(|| serde::de::Error::custom(format!("bad relation key {raw:?}")))?;
|
|
|
|
|
|
let parse = |s: &str| {
|
|
|
|
|
|
s.parse::<u8>()
|
|
|
|
|
|
.map_err(|e| serde::de::Error::custom(format!("bad seat in {raw:?}: {e}")))
|
|
|
|
|
|
};
|
|
|
|
|
|
Ok(Pair::new(PlayerId(parse(a)?), PlayerId(parse(b)?)))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// GR-O01..O05: the authoritative GROUND aggregate. Fields use ordered
|
|
|
|
|
|
/// collections only (GameKernel K6).
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct GroundState {
|
|
|
|
|
|
pub round: u8,
|
|
|
|
|
|
pub lead: PlayerId,
|
|
|
|
|
|
pub players: BTreeMap<PlayerId, PlayerState>,
|
|
|
|
|
|
/// Keyed by (low, high) player pair.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
pub relations: BTreeMap<Pair, Relation>,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
pub problems: BTreeMap<u32, ProblemState>,
|
|
|
|
|
|
pub solution_deck: Vec<SolutionCard>,
|
|
|
|
|
|
pub solution_discard: Vec<SolutionCard>,
|
|
|
|
|
|
/// Focus placements: sequence owner → target (GR-T03).
|
|
|
|
|
|
pub focus: BTreeMap<PlayerId, PlayerId>,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-R01: which of the four steps the round is in.
|
|
|
|
|
|
pub step: RoundStep,
|
|
|
|
|
|
/// GR-R02: face-down selections, hidden until Reveal.
|
|
|
|
|
|
pub selections: BTreeMap<PlayerId, Selection>,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
|
|
|
|
|
|
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// 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>,
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
|
|
|
|
|
|
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// GR-E02..E04: which scoring mode this game uses.
|
|
|
|
|
|
pub mode: ScoringMode,
|
|
|
|
|
|
/// GR-R09: set once the game has ended and scoring has run.
|
|
|
|
|
|
pub outcome: Option<Outcome>,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
|
|
|
|
|
|
/// state, keeping `validate` deterministic without holding RNG state.
|
|
|
|
|
|
pub seed: u64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// GR-E02..E04: the three scoring modes.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub enum ScoringMode {
|
|
|
|
|
|
/// GR-E02, co-op: one shared score against the threshold.
|
|
|
|
|
|
SharedGround,
|
|
|
|
|
|
/// GR-E03, semi-co-op: personal scores once the group qualifies.
|
|
|
|
|
|
CommonProblem,
|
|
|
|
|
|
/// GR-E04: Bond networks score together.
|
|
|
|
|
|
BondedCoalitions,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-E01..E04: the final scoring result.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct Outcome {
|
|
|
|
|
|
/// GR-E01: summed printed values of all claimed Problems.
|
|
|
|
|
|
pub total: u32,
|
|
|
|
|
|
pub threshold: u32,
|
|
|
|
|
|
pub group_success: bool,
|
|
|
|
|
|
/// GR-E03: claimed value −1 per Blame held.
|
|
|
|
|
|
pub personal: BTreeMap<PlayerId, i32>,
|
|
|
|
|
|
/// GR-E04: each Bond-connected group and its combined score.
|
|
|
|
|
|
pub coalitions: Vec<Coalition>,
|
|
|
|
|
|
/// GR-E02: only meaningful in SHARED GROUND.
|
|
|
|
|
|
pub mastery: Option<i32>,
|
|
|
|
|
|
pub winners: Vec<PlayerId>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-E04: one Bond-connected group. Unbonded players are solo.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct Coalition {
|
|
|
|
|
|
pub members: Vec<PlayerId>,
|
|
|
|
|
|
pub score: i32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// 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>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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 {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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:?}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
impl GroundMode {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
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)"
|
|
|
|
|
|
)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R01: 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 {
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn parse(raw: &str) -> Result<Self, String> {
|
|
|
|
|
|
match raw {
|
|
|
|
|
|
"INVESTIGATE" => Ok(Action::Investigate),
|
|
|
|
|
|
"SOLVE" => Ok(Action::Solve),
|
|
|
|
|
|
"SUPPORT" => Ok(Action::Support),
|
|
|
|
|
|
"ATTACK" => Ok(Action::Attack),
|
|
|
|
|
|
"GROUND" => Ok(Action::Ground),
|
|
|
|
|
|
other => Err(format!("unknown action {other:?}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R03: the stress gate admits only ATTACK and GROUND.
|
|
|
|
|
|
fn allowed_under_stress_gate(self) -> bool {
|
|
|
|
|
|
matches!(self, Action::Attack | Action::Ground)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-A13: SUPPORT and ATTACK target another player; INVESTIGATE and
|
|
|
|
|
|
/// SOLVE target a Problem; GROUND targets neither at Select.
|
|
|
|
|
|
fn requires_player_target(self) -> bool {
|
|
|
|
|
|
matches!(self, Action::Support | Action::Attack)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn requires_problem_target(self) -> bool {
|
|
|
|
|
|
matches!(self, Action::Investigate | Action::Solve)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Commands accepted by the GROUND aggregate.
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub enum GroundCommand {
|
|
|
|
|
|
/// GR-R02: choose an Action face down.
|
|
|
|
|
|
SelectAction {
|
|
|
|
|
|
action: Action,
|
|
|
|
|
|
target: Option<PlayerId>,
|
|
|
|
|
|
problem: Option<u32>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
|
|
|
|
|
|
SpendFreedom,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// GR-R05: a player who revealed GROUND chooses its mode after
|
|
|
|
|
|
/// seeing all revealed Actions.
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode,
|
|
|
|
|
|
choice: Option<GroundChoice>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-L02/A05: respond to a Support aimed at this player.
|
|
|
|
|
|
RespondToSupport { response: SupportResponse },
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// GR-D03/D04: name the mandatory target of this round's stage.
|
|
|
|
|
|
ChooseDarvoTarget { target: DarvoTarget },
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R04: reveal all selections simultaneously. System-driven.
|
|
|
|
|
|
Reveal,
|
|
|
|
|
|
/// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
|
|
|
|
|
|
/// first. System-driven.
|
|
|
|
|
|
Resolve,
|
|
|
|
|
|
/// GR-R08: clamp Stress, trigger DARVO, rotate Lead, advance the
|
|
|
|
|
|
/// Round marker. System-driven.
|
|
|
|
|
|
EndRound,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Events the aggregate emits. `fold` is total over these (K1).
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
|
#[serde(tag = "kind")]
|
|
|
|
|
|
pub enum GroundEvent {
|
|
|
|
|
|
ActionSelected {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
selection: Selection,
|
|
|
|
|
|
},
|
|
|
|
|
|
FreedomSpent {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R04.
|
|
|
|
|
|
Revealed,
|
|
|
|
|
|
/// GR-F01/U2: absolute post-clamp Stress, so `fold` stays trivial.
|
|
|
|
|
|
StressSet {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
stress: u8,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-F04.
|
|
|
|
|
|
FreedomReadied {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-L02/L03: endpoints ordered low→high (GR-O05).
|
|
|
|
|
|
RelationFormed {
|
|
|
|
|
|
pair: Pair,
|
|
|
|
|
|
relation: Relation,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-L04.
|
|
|
|
|
|
RelationBroken {
|
|
|
|
|
|
pair: Pair,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-A09: Protection absorbed an Attack.
|
|
|
|
|
|
AttackCancelled {
|
|
|
|
|
|
attacker: PlayerId,
|
|
|
|
|
|
target: PlayerId,
|
|
|
|
|
|
},
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// GR-R05/A11/A12.
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
GroundModeChosen {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
mode: GroundMode,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
},
|
|
|
|
|
|
/// 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>,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-D01: Stress 5 at End with the marker OFF.
|
|
|
|
|
|
DarvoTriggered {
|
|
|
|
|
|
player: PlayerId,
|
|
|
|
|
|
},
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
},
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// GR-R09/E01..E04: the game ended and scoring ran.
|
|
|
|
|
|
GameEnded {
|
|
|
|
|
|
outcome: Outcome,
|
|
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R01: the round advanced to `step`.
|
|
|
|
|
|
StepAdvanced {
|
|
|
|
|
|
step: RoundStep,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// GR-R08: Lead rotated and the Round marker advanced.
|
|
|
|
|
|
RoundEnded {
|
|
|
|
|
|
round: u8,
|
|
|
|
|
|
next_lead: PlayerId,
|
|
|
|
|
|
},
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
|
|
|
|
|
|
self.relations.get(&Pair::new(a, b)).copied()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-L01: two relation slots per player.
|
|
|
|
|
|
fn has_free_slot(&self, player: PlayerId) -> bool {
|
|
|
|
|
|
let used = self
|
|
|
|
|
|
.relations
|
|
|
|
|
|
.keys()
|
|
|
|
|
|
.filter(|pair| pair.contains(player))
|
|
|
|
|
|
.count();
|
|
|
|
|
|
used < 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R07: resolution order starts at the Lead and continues
|
|
|
|
|
|
/// clockwise (ascending seat, wrapping).
|
|
|
|
|
|
fn seat_order(&self) -> Vec<PlayerId> {
|
|
|
|
|
|
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
|
|
|
|
|
|
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
|
|
|
|
|
|
seats[start..]
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.chain(&seats[..start])
|
|
|
|
|
|
.copied()
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-F01 with the U2 default: clamp on every application, so no
|
|
|
|
|
|
/// intermediate value escapes 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn player(&self, id: PlayerId) -> Result<&PlayerState, Rejection> {
|
|
|
|
|
|
self.players.get(&id).ok_or(Rejection::Game {
|
|
|
|
|
|
code: "no-such-seat".into(),
|
|
|
|
|
|
detail: format!("player {id} is not in this game"),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Aggregate for GroundState {
|
|
|
|
|
|
type Command = GroundCommand;
|
|
|
|
|
|
type Event = GroundEvent;
|
|
|
|
|
|
|
|
|
|
|
|
fn validate(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
actor: Actor,
|
|
|
|
|
|
command: &Self::Command,
|
|
|
|
|
|
) -> Result<Vec<Self::Event>, Rejection> {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
// GR-R04/R06/R08 are runtime-driven, not player-issued.
|
|
|
|
|
|
match command {
|
|
|
|
|
|
GroundCommand::Reveal => {
|
|
|
|
|
|
if self.step != RoundStep::Select || actor != Actor::System {
|
|
|
|
|
|
return Err(Rejection::NotAllowedNow);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.selections.len() != self.players.len() {
|
|
|
|
|
|
return Err(Rejection::Game {
|
|
|
|
|
|
code: "select-incomplete".into(),
|
|
|
|
|
|
detail: "GR-R04: every player must select before Reveal".into(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return Ok(vec![
|
|
|
|
|
|
GroundEvent::Revealed,
|
|
|
|
|
|
GroundEvent::StepAdvanced {
|
|
|
|
|
|
step: RoundStep::Reveal,
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundCommand::Resolve => {
|
|
|
|
|
|
if self.step != RoundStep::Reveal || actor != Actor::System {
|
|
|
|
|
|
return Err(Rejection::NotAllowedNow);
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// 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:?}"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
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 }])
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// GR-R05: only after Reveal, only for a player who revealed
|
|
|
|
|
|
// GROUND, once.
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
GroundCommand::ChooseGroundMode { mode, choice } => {
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
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;
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// 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)?;
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
Ok(vec![GroundEvent::GroundModeChosen {
|
|
|
|
|
|
player: id,
|
|
|
|
|
|
mode: *mode,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}])
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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,
|
|
|
|
|
|
}])
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
|
|
|
|
|
|
unreachable!("system commands are handled above")
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn fold(&mut self, event: &Self::Event) {
|
|
|
|
|
|
match event {
|
|
|
|
|
|
GroundEvent::ActionSelected { player, selection } => {
|
|
|
|
|
|
self.selections.insert(*player, *selection);
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::FreedomSpent { player } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.freedom_ready = false;
|
|
|
|
|
|
state.freedom_gate_lifted = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundEvent::Revealed => {}
|
|
|
|
|
|
GroundEvent::StressSet { player, stress } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.stress = *stress;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::FreedomReadied { player } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.freedom_ready = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::RelationFormed { pair, relation } => {
|
|
|
|
|
|
self.relations.insert(*pair, *relation);
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::RelationBroken { pair } => {
|
|
|
|
|
|
self.relations.remove(pair);
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::AttackCancelled { target, .. } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(target) {
|
|
|
|
|
|
state.protection = state.protection.saturating_sub(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
GroundEvent::GroundModeChosen {
|
|
|
|
|
|
player,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
choice,
|
|
|
|
|
|
} => {
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
self.ground_modes.insert(*player, *mode);
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundEvent::DarvoTriggered { player } => {
|
|
|
|
|
|
if let Some(state) = self.players.get_mut(player) {
|
|
|
|
|
|
state.darvo = DarvoStage::Deny;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-31 02:33:27 +02:00
|
|
|
|
GroundEvent::GameEnded { outcome } => {
|
|
|
|
|
|
self.outcome = Some(outcome.clone());
|
|
|
|
|
|
self.step = RoundStep::End;
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
GroundEvent::StepAdvanced { step } => {
|
|
|
|
|
|
self.step = *step;
|
|
|
|
|
|
}
|
|
|
|
|
|
GroundEvent::RoundEnded { round, next_lead } => {
|
|
|
|
|
|
self.round = *round;
|
|
|
|
|
|
self.lead = *next_lead;
|
|
|
|
|
|
self.selections.clear();
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
self.ground_modes.clear();
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
self.ground_choices.clear();
|
|
|
|
|
|
self.support_responses.clear();
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
self.darvo_targets.clear();
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GroundState {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
|
|
|
|
|
|
///
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// Step 3 (active DARVO stages) is not yet implemented — the DARVO
|
|
|
|
|
|
/// machine lands with GR-D02..D07 and no scenario claims coverage of
|
|
|
|
|
|
/// it. GROUND—OU and GROUND—ND (GR-A11/A12) each offer a three-way
|
|
|
|
|
|
/// choice that needs its own command and are likewise pending.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
fn 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();
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A11: Attacks cancelled by a GROUND—OU choice this round.
|
|
|
|
|
|
let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> =
|
|
|
|
|
|
std::collections::BTreeSet::new();
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// 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 => {}
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A05: Support through a Rivalry — −1 Stress, then
|
|
|
|
|
|
// the target flips it to a Bond or breaks it.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
Some(Relation::Rivalry) => {
|
|
|
|
|
|
let stress = work.stress_after(target, -1);
|
|
|
|
|
|
events.push(GroundEvent::StressSet {
|
|
|
|
|
|
player: target,
|
|
|
|
|
|
stress,
|
|
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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.
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
// GR-A03/L02: no relation — −1 Stress, and a Bond forms
|
|
|
|
|
|
// only if the target accepts and both have a free slot.
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
None => {
|
|
|
|
|
|
let stress = work.stress_after(target, -1);
|
|
|
|
|
|
events.push(GroundEvent::StressSet {
|
|
|
|
|
|
player: target,
|
|
|
|
|
|
stress,
|
|
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
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"));
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// Step 3 — active DARVO stages (GR-D02..D07).
|
|
|
|
|
|
for owner in self.seat_order() {
|
|
|
|
|
|
let stage = work.players[&owner].darvo;
|
|
|
|
|
|
if stage == DarvoStage::Off {
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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 });
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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,
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
});
|
|
|
|
|
|
work.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
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"));
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
events.push(GroundEvent::StepAdvanced {
|
|
|
|
|
|
step: RoundStep::Resolve,
|
|
|
|
|
|
});
|
|
|
|
|
|
events
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
/// GR-A06..A09: one Attack under the normal relation rules. Shared
|
|
|
|
|
|
/// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's
|
|
|
|
|
|
/// extra Attack (GR-D04).
|
|
|
|
|
|
fn resolve_attack(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
attacker: PlayerId,
|
|
|
|
|
|
target: PlayerId,
|
|
|
|
|
|
ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>,
|
|
|
|
|
|
events: &mut Vec<GroundEvent>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
// GR-A09 under the U8 default: a GROUND—OU cancellation is
|
|
|
|
|
|
// chosen at step 1 and applies first, so Protection is only
|
|
|
|
|
|
// consumed when it is what actually cancels.
|
|
|
|
|
|
if ou_cancels.contains(&(attacker, target)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// GR-A09/T01: Protection absorbs the Attack entirely.
|
|
|
|
|
|
if self.players[&target].protection > 0 {
|
|
|
|
|
|
events.push(GroundEvent::AttackCancelled { attacker, target });
|
|
|
|
|
|
self.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let pair = Pair::new(attacker, target);
|
|
|
|
|
|
let (delta, after) = match self.relation_between(attacker, target) {
|
|
|
|
|
|
// GR-A07: through a Bond — +2 and the Bond flips.
|
|
|
|
|
|
Some(Relation::Bond) => (
|
|
|
|
|
|
2,
|
|
|
|
|
|
Some(GroundEvent::RelationFormed {
|
|
|
|
|
|
pair,
|
|
|
|
|
|
relation: Relation::Rivalry,
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
|
|
|
|
|
// GR-A08: through a Rivalry — +2 and it breaks.
|
|
|
|
|
|
Some(Relation::Rivalry) => (2, Some(GroundEvent::RelationBroken { pair })),
|
|
|
|
|
|
// GR-A06: no relation — +1, and a Rivalry forms without
|
|
|
|
|
|
// consent if both endpoints have a slot (GR-L01/L03).
|
|
|
|
|
|
None => {
|
|
|
|
|
|
let forms = self.has_free_slot(attacker) && self.has_free_slot(target);
|
|
|
|
|
|
(
|
|
|
|
|
|
1,
|
|
|
|
|
|
forms.then_some(GroundEvent::RelationFormed {
|
|
|
|
|
|
pair,
|
|
|
|
|
|
relation: Relation::Rivalry,
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let stress = self.stress_after(target, delta);
|
|
|
|
|
|
events.push(GroundEvent::StressSet {
|
|
|
|
|
|
player: target,
|
|
|
|
|
|
stress,
|
|
|
|
|
|
});
|
|
|
|
|
|
self.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
if let Some(event) = after {
|
|
|
|
|
|
events.push(event);
|
|
|
|
|
|
self.fold(events.last().expect("just pushed"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
/// 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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// GR-R08: Stress is already clamped on every application (U2), so
|
|
|
|
|
|
/// End only triggers DARVO, rotates the Lead, and advances the Round.
|
|
|
|
|
|
fn end_round_events(&self) -> Vec<GroundEvent> {
|
|
|
|
|
|
let mut events = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
// GR-D01: Stress 5 with the marker OFF starts a sequence. In
|
|
|
|
|
|
// Lead order, so two simultaneous triggers are ordered (U9).
|
|
|
|
|
|
for seat in self.seat_order() {
|
|
|
|
|
|
let player = &self.players[&seat];
|
|
|
|
|
|
if player.stress == 5 && player.darvo == DarvoStage::Off {
|
|
|
|
|
|
events.push(GroundEvent::DarvoTriggered { player: seat });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
|
|
|
|
|
|
let next_lead = seats
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|s| *s == self.lead)
|
|
|
|
|
|
.map_or(self.lead, |i| seats[(i + 1) % seats.len()]);
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
// GR-R09: after Round 5's End the game ends and scoring applies.
|
|
|
|
|
|
if self.round >= 5 {
|
|
|
|
|
|
events.push(GroundEvent::GameEnded {
|
|
|
|
|
|
outcome: self.score(),
|
|
|
|
|
|
});
|
|
|
|
|
|
return events;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
events.push(GroundEvent::RoundEnded {
|
|
|
|
|
|
round: self.round + 1,
|
|
|
|
|
|
next_lead,
|
|
|
|
|
|
});
|
|
|
|
|
|
events.push(GroundEvent::StepAdvanced {
|
|
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
});
|
|
|
|
|
|
events
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 02:33:27 +02:00
|
|
|
|
/// GR-E01: the Scenario threshold by player count (dataset 0.1).
|
|
|
|
|
|
fn threshold(&self) -> u32 {
|
|
|
|
|
|
match self.players.len() {
|
|
|
|
|
|
0..=2 => 5,
|
|
|
|
|
|
3..=4 => 7,
|
|
|
|
|
|
_ => 9,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-E01..E04: final scoring for the configured mode.
|
|
|
|
|
|
fn score(&self) -> Outcome {
|
|
|
|
|
|
// GR-E01/P03: a claimed Problem counts its printed value.
|
|
|
|
|
|
let total: u32 = self
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| p.claimed_by.is_some())
|
|
|
|
|
|
.map(|p| u32::from(p.value))
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let threshold = self.threshold();
|
|
|
|
|
|
let group_success = total >= threshold;
|
|
|
|
|
|
|
|
|
|
|
|
// GR-E03/T02: claimed value −1 per Blame token held.
|
|
|
|
|
|
let personal: BTreeMap<PlayerId, i32> = self
|
|
|
|
|
|
.players
|
|
|
|
|
|
.keys()
|
|
|
|
|
|
.map(|seat| {
|
|
|
|
|
|
let claimed: i32 = self
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| p.claimed_by == Some(*seat))
|
|
|
|
|
|
.map(|p| i32::from(p.value))
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let blame = self.players[seat].blame_from.len() as i32;
|
|
|
|
|
|
(*seat, claimed - blame)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
let coalitions = self.coalitions(&personal);
|
|
|
|
|
|
|
|
|
|
|
|
let (mastery, winners) = match self.mode {
|
|
|
|
|
|
// GR-E02: one shared score; no individual winner.
|
|
|
|
|
|
ScoringMode::SharedGround => {
|
|
|
|
|
|
let blame: i32 = self
|
|
|
|
|
|
.players
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.map(|p| p.blame_from.len() as i32)
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let denied = self.problems.values().filter(|p| p.denied).count() as i32;
|
|
|
|
|
|
let claimed = self
|
|
|
|
|
|
.problems
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.filter(|p| p.claimed_by.is_some())
|
|
|
|
|
|
.count() as i32;
|
|
|
|
|
|
let mastery = claimed - blame - denied;
|
|
|
|
|
|
let winners = if group_success {
|
|
|
|
|
|
self.players.keys().copied().collect()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Vec::new()
|
|
|
|
|
|
};
|
|
|
|
|
|
(Some(mastery), winners)
|
|
|
|
|
|
}
|
|
|
|
|
|
// GR-E03: highest personal score, once the group qualifies.
|
|
|
|
|
|
// Tiebreak: lower Stress, then more Bonds, then shared.
|
|
|
|
|
|
ScoringMode::CommonProblem => {
|
|
|
|
|
|
let winners = if group_success {
|
|
|
|
|
|
self.best(personal.keys().copied().collect(), |seat| {
|
|
|
|
|
|
(
|
|
|
|
|
|
personal[&seat],
|
|
|
|
|
|
-i32::from(self.players[&seat].stress),
|
|
|
|
|
|
self.bond_count(seat),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Vec::new()
|
|
|
|
|
|
};
|
|
|
|
|
|
(None, winners)
|
|
|
|
|
|
}
|
|
|
|
|
|
// GR-E04: highest coalition. Tiebreak: lower combined
|
|
|
|
|
|
// Stress, then fewer Blame tokens, then shared.
|
|
|
|
|
|
ScoringMode::BondedCoalitions => {
|
|
|
|
|
|
let winners = if group_success {
|
|
|
|
|
|
let best = self.best((0..coalitions.len()).collect(), |i| {
|
|
|
|
|
|
let c = &coalitions[i];
|
|
|
|
|
|
let stress: i32 = c
|
|
|
|
|
|
.members
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|m| i32::from(self.players[m].stress))
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let blame: i32 = c
|
|
|
|
|
|
.members
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|m| self.players[m].blame_from.len() as i32)
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
(c.score, -stress, -blame)
|
|
|
|
|
|
});
|
|
|
|
|
|
let mut winners: Vec<PlayerId> = best
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flat_map(|i| coalitions[i].members.clone())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
winners.sort();
|
|
|
|
|
|
winners
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Vec::new()
|
|
|
|
|
|
};
|
|
|
|
|
|
(None, winners)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Outcome {
|
|
|
|
|
|
total,
|
|
|
|
|
|
threshold,
|
|
|
|
|
|
group_success,
|
|
|
|
|
|
personal,
|
|
|
|
|
|
coalitions,
|
|
|
|
|
|
mastery,
|
|
|
|
|
|
winners,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every candidate tied on the ranking key, so a tie is a shared
|
|
|
|
|
|
/// result rather than an arbitrary pick (GR-E03/E04).
|
|
|
|
|
|
fn best<T: Copy, K: Ord>(&self, candidates: Vec<T>, key: impl Fn(T) -> K) -> Vec<T> {
|
|
|
|
|
|
let Some(top) = candidates.iter().map(|c| key(*c)).max() else {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
};
|
|
|
|
|
|
candidates.into_iter().filter(|c| key(*c) == top).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn bond_count(&self, seat: PlayerId) -> i32 {
|
|
|
|
|
|
self.relations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(pair, rel)| **rel == Relation::Bond && pair.contains(seat))
|
|
|
|
|
|
.count() as i32
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-E04: connected components over Bonds only; Rivalries do not
|
|
|
|
|
|
/// connect, and an unbonded player is a coalition of one.
|
|
|
|
|
|
fn coalitions(&self, personal: &BTreeMap<PlayerId, i32>) -> Vec<Coalition> {
|
|
|
|
|
|
let mut remaining: Vec<PlayerId> = self.players.keys().copied().collect();
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
while let Some(seed) = remaining.first().copied() {
|
|
|
|
|
|
let mut members = vec![seed];
|
|
|
|
|
|
let mut frontier = vec![seed];
|
|
|
|
|
|
remaining.retain(|p| *p != seed);
|
|
|
|
|
|
while let Some(current) = frontier.pop() {
|
|
|
|
|
|
let neighbours: Vec<PlayerId> = self
|
|
|
|
|
|
.relations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(_, rel)| **rel == Relation::Bond)
|
|
|
|
|
|
.filter_map(|(pair, _)| {
|
|
|
|
|
|
if pair.0 == current {
|
|
|
|
|
|
Some(pair.1)
|
|
|
|
|
|
} else if pair.1 == current {
|
|
|
|
|
|
Some(pair.0)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(|n| remaining.contains(n))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
for n in neighbours {
|
|
|
|
|
|
remaining.retain(|p| *p != n);
|
|
|
|
|
|
members.push(n);
|
|
|
|
|
|
frontier.push(n);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
members.sort();
|
|
|
|
|
|
let score = members.iter().map(|m| personal[m]).sum();
|
|
|
|
|
|
out.push(Coalition { members, score });
|
|
|
|
|
|
}
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
/// 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(()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
/// GR-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")))?;
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
} else if problem.is_some() {
|
|
|
|
|
|
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S01: hidden-Problem priorities admitted per player count.
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn problem_priorities(players: u8) -> Result<u8, String> {
|
|
|
|
|
|
match players {
|
|
|
|
|
|
2 => Ok(2),
|
|
|
|
|
|
3..=4 => Ok(3),
|
|
|
|
|
|
5..=6 => Ok(4),
|
|
|
|
|
|
other => Err(format!("GR-S01: unsupported player count {other}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S04: the 24 core Solution cards, 6 per suit, in canonical order
|
|
|
|
|
|
/// before the seeded shuffle.
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn core_solution_deck() -> Vec<SolutionCard> {
|
|
|
|
|
|
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AM-4: gate scenario YAML, retarget on audited source, re-measure
Adopts both remediations from CB-EV-0001 §4 (maintainer decision).
Option A — serde_yaml is now optional behind cb-game-runtime's
`scenarios` feature. The scenario module, the ScenarioGame impl and the
string parsers behind it are cfg-gated; cb-sim opts in explicitly. Both
configurations compile and lint clean under -D warnings.
A trap worth recording: `default-features = false` on a *member*
dependency is silently ignored when the workspace dependency does not
specify it. The first attempt gated nothing while looking correct — the
build succeeded and cargo tree still showed all six YAML crates. Fixed
by setting it on the workspace dependency. This is the positive-control
failure mode in miniature: success was not evidence the change applied.
Retarget — AM-4 now measures third-party source under audit, split by
build configuration, replacing a crate count that was unreachable
without undoing K5/K7 and that does not compare across ecosystems.
Re-measured via the new `make dep-weight`, whose own positive control
refuses to report when any crate's source cannot be located:
shipped runtime 23 crates 246,250 lines target <=250,000 met
dev toolchain 29 crates 317,021 lines target <=350,000 met
own source 3,408 lines
Scenario tooling costs 70,771 lines a shipped game never compiles —
the split the single number was hiding.
Targets are set at current measurement plus headroom, so they bind on
future growth rather than retroactively passing what had failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:35:41 +02:00
|
|
|
|
#[cfg(feature = "scenarios")]
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
impl ScenarioGame for GroundState {
|
|
|
|
|
|
/// GR-S01..S04. The `standard-Np` presets differ only in seat count;
|
|
|
|
|
|
/// Problem content is scenario data, so the preset uses the canonical
|
|
|
|
|
|
/// fixture below (suit cycling by priority) until scenario decks are
|
|
|
|
|
|
/// modelled.
|
|
|
|
|
|
fn setup(setup: &Setup, seed: u64) -> Result<Self, String> {
|
|
|
|
|
|
let seats = setup.players;
|
|
|
|
|
|
let expected = format!("standard-{seats}p");
|
|
|
|
|
|
if setup.preset != expected {
|
|
|
|
|
|
return Err(format!(
|
|
|
|
|
|
"preset {:?} does not match {seats} players (expected {expected:?})",
|
|
|
|
|
|
setup.preset
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
let priorities = problem_priorities(seats)?;
|
|
|
|
|
|
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
|
|
|
|
|
|
|
|
|
|
|
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
|
|
|
|
|
let mut deck = core_solution_deck();
|
|
|
|
|
|
rng.shuffle(&mut deck);
|
|
|
|
|
|
|
|
|
|
|
|
// GR-S02: Stress 2, Freedom READY, DARVO OFF, two Solution cards.
|
|
|
|
|
|
let mut players = BTreeMap::new();
|
|
|
|
|
|
for seat in 0..seats {
|
|
|
|
|
|
let hand = deck.split_off(deck.len() - 2);
|
|
|
|
|
|
players.insert(
|
|
|
|
|
|
PlayerId(seat),
|
|
|
|
|
|
PlayerState {
|
|
|
|
|
|
stress: 2,
|
|
|
|
|
|
freedom_ready: true,
|
|
|
|
|
|
freedom_gate_lifted: false,
|
|
|
|
|
|
darvo: DarvoStage::Off,
|
|
|
|
|
|
hand,
|
|
|
|
|
|
protection: 0,
|
|
|
|
|
|
blame_from: vec![],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GR-S01: priority 1 is the Surface Problem, face up; the rest
|
|
|
|
|
|
// start face down.
|
|
|
|
|
|
let suits = [Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change];
|
|
|
|
|
|
let problems = (1..=u32::from(priorities))
|
|
|
|
|
|
.map(|priority| {
|
|
|
|
|
|
(
|
|
|
|
|
|
priority,
|
|
|
|
|
|
ProblemState {
|
|
|
|
|
|
suit: suits[(priority as usize - 1) % suits.len()],
|
|
|
|
|
|
value: priority as u8,
|
|
|
|
|
|
face_up: priority == 1,
|
|
|
|
|
|
denied: false,
|
|
|
|
|
|
claimed_by: None,
|
|
|
|
|
|
protected_this_round: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
// GR-S03: seeded-random Lead, Round 1.
|
|
|
|
|
|
let lead = PlayerId(rng.draw(u32::from(seats)) as u8);
|
|
|
|
|
|
|
|
|
|
|
|
Ok(GroundState {
|
|
|
|
|
|
round: 1,
|
|
|
|
|
|
lead,
|
|
|
|
|
|
players,
|
|
|
|
|
|
relations: BTreeMap::new(),
|
|
|
|
|
|
problems,
|
|
|
|
|
|
solution_deck: deck,
|
|
|
|
|
|
solution_discard: vec![],
|
|
|
|
|
|
focus: BTreeMap::new(),
|
|
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
selections: BTreeMap::new(),
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
ground_modes: BTreeMap::new(),
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ground_choices: BTreeMap::new(),
|
|
|
|
|
|
support_responses: BTreeMap::new(),
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
darvo_targets: BTreeMap::new(),
|
2026-07-31 02:33:27 +02:00
|
|
|
|
mode: ScoringMode::SharedGround,
|
|
|
|
|
|
outcome: None,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
seed,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_command(step: &CommandStep) -> Result<(Actor, Self::Command), String> {
|
|
|
|
|
|
let actor = parse_actor(&step.actor)?;
|
|
|
|
|
|
let arg_str = |key: &str| -> Result<String, String> {
|
|
|
|
|
|
step.args
|
|
|
|
|
|
.get(key)
|
|
|
|
|
|
.and_then(|v| v.as_str().map(str::to_string))
|
|
|
|
|
|
.ok_or_else(|| format!("{}: missing string arg {key:?}", step.cmd))
|
|
|
|
|
|
};
|
|
|
|
|
|
let arg_u64 = |key: &str| -> Option<u64> { step.args.get(key).and_then(|v| v.as_u64()) };
|
|
|
|
|
|
|
|
|
|
|
|
let command = match step.cmd.as_str() {
|
|
|
|
|
|
"select_action" => {
|
|
|
|
|
|
let action = Action::parse(&arg_str("action")?)?;
|
|
|
|
|
|
let target = match step.args.get("target") {
|
|
|
|
|
|
Some(_) => match parse_actor(&arg_str("target")?)? {
|
|
|
|
|
|
Actor::Player(id) => Some(id),
|
|
|
|
|
|
Actor::System => return Err("target may not be SYSTEM".into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
None => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
GroundCommand::SelectAction {
|
|
|
|
|
|
action,
|
|
|
|
|
|
target,
|
|
|
|
|
|
problem: arg_u64("problem").map(|p| p as u32),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"spend_freedom" => GroundCommand::SpendFreedom,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
"choose_ground_mode" => GroundCommand::ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode::parse(&arg_str("mode")?)?,
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
choice: match step.args.get("choice") {
|
|
|
|
|
|
Some(_) => Some(GroundChoice::parse(
|
|
|
|
|
|
&arg_str("choice")?,
|
|
|
|
|
|
arg_u64("problem").or_else(|| arg_u64("seat")),
|
|
|
|
|
|
)?),
|
|
|
|
|
|
None => None,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
"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,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
"respond_to_support" => GroundCommand::RespondToSupport {
|
|
|
|
|
|
response: SupportResponse::parse(&arg_str("response")?)?,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
},
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
"reveal" => GroundCommand::Reveal,
|
|
|
|
|
|
"resolve" => GroundCommand::Resolve,
|
|
|
|
|
|
"end_round" => GroundCommand::EndRound,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
other => return Err(format!("unknown command {other:?}")),
|
|
|
|
|
|
};
|
|
|
|
|
|
Ok((actor, command))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn round(&self) -> u8 {
|
|
|
|
|
|
self.round
|
|
|
|
|
|
}
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use cb_events::state_hash_hex;
|
|
|
|
|
|
|
|
|
|
|
|
fn tiny_state() -> GroundState {
|
|
|
|
|
|
GroundState {
|
|
|
|
|
|
round: 1,
|
|
|
|
|
|
lead: PlayerId(0),
|
|
|
|
|
|
players: BTreeMap::from([(
|
|
|
|
|
|
PlayerId(0),
|
|
|
|
|
|
PlayerState {
|
|
|
|
|
|
stress: 2,
|
|
|
|
|
|
freedom_ready: true,
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
freedom_gate_lifted: false,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
darvo: DarvoStage::Off,
|
|
|
|
|
|
hand: vec![SolutionCard { suit: Suit::Repair }],
|
|
|
|
|
|
protection: 0,
|
|
|
|
|
|
blame_from: vec![],
|
|
|
|
|
|
},
|
|
|
|
|
|
)]),
|
|
|
|
|
|
relations: BTreeMap::new(),
|
|
|
|
|
|
problems: BTreeMap::new(),
|
|
|
|
|
|
solution_deck: vec![],
|
|
|
|
|
|
solution_discard: vec![],
|
|
|
|
|
|
focus: BTreeMap::new(),
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
step: RoundStep::Select,
|
|
|
|
|
|
selections: BTreeMap::new(),
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
ground_modes: BTreeMap::new(),
|
T08 iter 4: GROUND-OU/ND choices and Support consent
Closes the three rules deferred in iter 2 and 3 because each needed a
decision from a player rather than a default I would have invented:
- GR-A11 GROUND-OU: restore a Denied Problem, protect a face-up one
from Deny, or cancel an Attack aimed at the chooser.
- GR-A12 GROUND-ND: remove a Blame token, break a relation involving
the chooser, or reject a Reverse.
- GR-L02 / GR-A05: the Support target answers after Reveal — accept or
decline a Bond where no relation exists, flip or break an existing
Rivalry. A Bond can no longer form without consent.
Each choice is validated against the state it claims to act on: a
restore needs a Denied Problem, a cancel needs an Attack actually aimed
at the chooser, a Blame removal needs that token present, a break needs
that relation to exist. GR takes no sub-choice, and OU and ND may only
use choices from their own list.
GR-A09 ordering follows the U8 default: an OU cancellation is chosen at
step 1 and applies first, so a Protection token is spent only when it
is what actually cancels.
14 scenarios pass; AM-1 coverage 41/58 (70%), up from 34/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:27:25 +02:00
|
|
|
|
ground_choices: BTreeMap::new(),
|
|
|
|
|
|
support_responses: BTreeMap::new(),
|
T08 iter 5: the DARVO sequence machine
Resolution step 3 (GR-R06), the last unimplemented step:
- GR-D02 binding: one stage per consecutive round, DENY then ATTACK
then REVERSE, regardless of the player's later Stress.
- GR-D03 DENY: turn one face-up, unsolved, unprotected Problem face
down and Deny it. Under the U3 default no legal target is a no-op and
the sequence still advances.
- GR-D04 ATTACK: one extra Attack under the normal relation rules, then
place the Focus token beside the target even if it was cancelled.
- GR-D05 REVERSE: flip Focus to Blame, +1 Stress to the holder, one
Protection to the owner, unless the holder's GROUND-ND rejects it.
Under the U5 default the owner takes -2 either way and the sequence
ends.
- GR-D06 early end: a Support through a Bond that predates this round's
Support step cancels the stage and ends the sequence, and the placed
Focus token is removed. GROUND-GR ends it after the stage resolves.
- GR-D07: the marker returns to OFF, so a later End can re-trigger.
The Attack rules are now one routine shared by the chosen ATTACK Action
and the DARVO extra Attack, so GR-A06..A09 cannot drift between them.
Stage targets are named by their own command during Reveal, validated
against what the stage admits: DENY needs an eligible Problem, ATTACK
another player, and only a player with a live sequence may choose.
18 scenarios pass; AM-1 coverage 47/58 (81%), up from 41/58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:30:58 +02:00
|
|
|
|
darvo_targets: BTreeMap::new(),
|
2026-07-31 02:33:27 +02:00
|
|
|
|
mode: ScoringMode::SharedGround,
|
|
|
|
|
|
outcome: None,
|
T08 iter 3: GROUND modes, INVESTIGATE, SOLVE
Fills in resolution steps 1, 4 and 6 (GR-R06):
- GR-R05 mode choice: a player who revealed GROUND picks GR/OU/ND after
Reveal. Resolution refuses to start while any revealed GROUND lacks a
mode, and only that player may choose it.
- GR-A10 GROUND-GR: self -2 Stress and Freedom readied.
- GR-A01 INVESTIGATE: reveal the chosen hidden non-Denied Problem, then
draw one Solution; the draw still happens when nothing is revealable.
- GR-A02 SOLVE: spend a Solution of the Problem's suit and claim it; a
later resolver the same round spends nothing, per Lead order.
- GR-A13 tightened: INVESTIGATE must target a hidden Problem, SOLVE a
face-up non-Denied one. Previously any existing Problem was accepted.
Deck exhaustion (U4) reshuffles the discard, seeded from the game seed
and round so validate stays a pure function of state. The resulting
order travels inside DeckReshuffled, so replay never re-derives it.
Still pending, each because it needs its own decision command rather
than a default: GROUND-OU and GROUND-ND three-way choices (GR-A11/A12)
and the DARVO stage machine (GR-D02..D07). No scenario claims coverage
of them.
Filler picks in existing scenarios moved from GROUND to INVESTIGATE:
GROUND now has a real Stress effect, which was polluting the Support
and Attack assertions.
11 scenarios pass, 34 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:23:09 +02:00
|
|
|
|
seed: 0,
|
2026-07-31 01:57:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 2: Reveal, Resolve, End; relations and the DARVO trigger
Round machinery, system-driven (GR-R04/R06/R08):
- GR-R06 fixed step order with GR-R07 Lead-first ordering inside a step.
Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE) are
not implemented yet; their Actions resolve as no-ops and no scenario
claims coverage of them.
- SUPPORT GR-A03/A04/A05 and ATTACK GR-A06..A09, with relation
formation, flip and break per GR-L01/L03/L04 and Protection
cancellation.
- GR-R08 End: DARVO trigger at Stress 5 in Lead order, Lead rotation,
Round advance, per-round flags cleared.
- Stress clamps 0-5 on every application, the U2 default, so a mid-round
spike that is reduced before End does not trigger DARVO.
Two consent-dependent rules are deliberately left out because they need
a decision command rather than a default: Bond formation (GR-L02) and
the target's flip-or-break choice on Support-through-Rivalry (GR-A05).
Both are noted in code and covered by a provisional scenario.
Fixes a defect in the T07 scaffold: relations were keyed by a tuple,
which JSON cannot use as an object key, so state_hash would have
panicked on any state holding a relation. Relation keys are now a Pair
newtype serialized as "a-b", with a regression test.
setup.patch may now create a final key so scenarios can seed open-ended
maps; a typo anywhere earlier in the path is still an error.
8 scenarios pass, 28 rules covered; 17 tests, fmt/clippy green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:19:34 +02:00
|
|
|
|
/// Regression: relation keys must serialize as JSON object keys.
|
|
|
|
|
|
/// With a tuple key, `state_hash` panicked on any state holding a
|
|
|
|
|
|
/// relation — that is, on almost every real game state.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn state_with_relations_hashes() {
|
|
|
|
|
|
let mut state = tiny_state();
|
|
|
|
|
|
state
|
|
|
|
|
|
.relations
|
|
|
|
|
|
.insert(Pair::new(PlayerId(1), PlayerId(0)), Relation::Bond);
|
|
|
|
|
|
let hash = state_hash_hex(&state);
|
|
|
|
|
|
assert_eq!(hash.len(), 64);
|
|
|
|
|
|
// GR-O05: the key is canonically ordered, so either construction
|
|
|
|
|
|
// order yields the same state and the same hash.
|
|
|
|
|
|
let mut mirrored = tiny_state();
|
|
|
|
|
|
mirrored
|
|
|
|
|
|
.relations
|
|
|
|
|
|
.insert(Pair::new(PlayerId(0), PlayerId(1)), Relation::Bond);
|
|
|
|
|
|
assert_eq!(hash, state_hash_hex(&mirrored));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
T08 iter 1: scenario runner executes; GROUND setup and Select step
Replaces the RunOutcome::Unimplemented stub with a real runner:
- ScenarioGame trait: games own setup presets and the command
vocabulary, the runner owns execution, assertions, and determinism.
- K8 double-run: every scenario runs twice on the same seed and fails
on state-hash divergence.
- K4/K11: applied events go through Envelope into EventLog, so seq
monotonicity is enforced on the real path, not just in unit tests.
- setup.patch was parsed and silently dropped; the runner now applies
it generically and errors on a path that does not exist, so a typo
in a scenario can never pass as a no-op.
- Assertions: dot-path state lookup over objects and arrays, ordered
event subsequence matching by field subset, exact rejects-set match.
GROUND rules realized: GR-S01..S04 setup (seeded shuffle, deal, Lead,
Surface Problem face up), GR-R02 Select commit, GR-R03 stress gate and
Freedom spend, GR-A13 targeting legality.
cb-sim dispatches by the scenario's game prefix and reports rule
coverage. 3 scenarios pass, 7 rules covered; fmt/clippy/tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:14:34 +02:00
|
|
|
|
fn setup_3p(seed: u64) -> GroundState {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: 3,
|
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
|
patch: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
seed,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
|
|
|
|
|
|
/// and the deck loses exactly what was dealt.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn setup_deals_per_gr_s02_and_s04() {
|
|
|
|
|
|
let state = setup_3p(42);
|
|
|
|
|
|
assert_eq!(state.players.len(), 3);
|
|
|
|
|
|
assert_eq!(state.round, 1);
|
|
|
|
|
|
for player in state.players.values() {
|
|
|
|
|
|
assert_eq!(player.stress, 2);
|
|
|
|
|
|
assert!(player.freedom_ready);
|
|
|
|
|
|
assert_eq!(player.darvo, DarvoStage::Off);
|
|
|
|
|
|
assert_eq!(player.hand.len(), 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(state.solution_deck.len(), 24 - 6);
|
|
|
|
|
|
// GR-S01: 3 players → priorities 1–3, priority 1 face up.
|
|
|
|
|
|
assert_eq!(state.problems.len(), 3);
|
|
|
|
|
|
assert!(state.problems[&1].face_up);
|
|
|
|
|
|
assert!(!state.problems[&2].face_up);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-S03/S04: the same seed reproduces setup exactly; a different
|
|
|
|
|
|
/// seed does not.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn setup_is_seed_deterministic() {
|
|
|
|
|
|
assert_eq!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(42)));
|
|
|
|
|
|
assert_ne!(state_hash_hex(&setup_3p(42)), state_hash_hex(&setup_3p(7)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R02: one selection per player per round.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn second_selection_is_a_duplicate() {
|
|
|
|
|
|
let mut state = setup_3p(42);
|
|
|
|
|
|
let cmd = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let events = state.validate(Actor::Player(PlayerId(0)), &cmd).unwrap();
|
|
|
|
|
|
for event in &events {
|
|
|
|
|
|
state.fold(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
state.validate(Actor::Player(PlayerId(0)), &cmd),
|
|
|
|
|
|
Err(Rejection::DuplicateCommand)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-R03: the stress gate blocks SUPPORT at Stress 4, and spending
|
|
|
|
|
|
/// Freedom lifts it.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn stress_gate_blocks_until_freedom_is_spent() {
|
|
|
|
|
|
let mut state = setup_3p(42);
|
|
|
|
|
|
state.players.get_mut(&PlayerId(0)).unwrap().stress = 4;
|
|
|
|
|
|
let support = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Support,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let attack = GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(1)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
state.validate(Actor::Player(PlayerId(0)), &support),
|
|
|
|
|
|
Err(Rejection::Game { ref code, .. }) if code == "stress-gate"
|
|
|
|
|
|
));
|
|
|
|
|
|
// ATTACK is always admitted by the gate.
|
|
|
|
|
|
assert!(state.validate(Actor::Player(PlayerId(0)), &attack).is_ok());
|
|
|
|
|
|
|
|
|
|
|
|
let spent = state
|
|
|
|
|
|
.validate(Actor::Player(PlayerId(0)), &GroundCommand::SpendFreedom)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
for event in &spent {
|
|
|
|
|
|
state.fold(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!state.players[&PlayerId(0)].freedom_ready);
|
|
|
|
|
|
assert!(state.validate(Actor::Player(PlayerId(0)), &support).is_ok());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// GR-A13: SUPPORT and ATTACK may not target their own player.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn self_targeting_is_rejected() {
|
|
|
|
|
|
let state = setup_3p(42);
|
|
|
|
|
|
let result = state.validate(
|
|
|
|
|
|
Actor::Player(PlayerId(0)),
|
|
|
|
|
|
&GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Attack,
|
|
|
|
|
|
target: Some(PlayerId(0)),
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
result,
|
|
|
|
|
|
Err(Rejection::Game { ref code, .. }) if code == "bad-target"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 01:57:13 +02:00
|
|
|
|
/// K7 on the real aggregate: hash stable across clones, sensitive to
|
|
|
|
|
|
/// semantic change.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ground_state_hashes_canonically() {
|
|
|
|
|
|
let a = tiny_state();
|
|
|
|
|
|
let b = a.clone();
|
|
|
|
|
|
assert_eq!(state_hash_hex(&a), state_hash_hex(&b));
|
|
|
|
|
|
let mut c = a.clone();
|
|
|
|
|
|
c.players.get_mut(&PlayerId(0)).unwrap().stress = 5;
|
|
|
|
|
|
assert_ne!(state_hash_hex(&a), state_hash_hex(&c));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
T08 complete: benchmarks, determinism evidence, and one missed metric
evidence/CB-EV-0001-game-kernel.md records the acceptance run against
the CB-RES-0001 baseline.
Met: AM-1 rule coverage 58/58; AM-6 throughput 1.65M events/s against a
100k target; AM-7 scaling 1.08x at 20x workload and a 100k-event replay
in 4.13ms against a 5s budget; AM-8 zero divergence over 10 full runs
with fmt and clippy clean; AM-10 zero foreign collection types.
Not met and reported as such: AM-4 at 33 transitive crates against a
<=20 target. Attribution is in the evidence file. The recommended fix
is making serde_yaml optional (-5, a test-only concern), after which
the remainder is sha2 and rand_chacha, which K5 and K7 require. We are
not hand-rolling crypto primitives to win a dependency count.
AM-12 is recorded as uncomputable: per-task token counts were never
instrumented, and inventing a USD figure would defeat the metric.
A measurement error was found and corrected before publication. The
first benchmark reported 9.3M events/s on a flat curve. The workload
had a player selecting SUPPORT while parked at Stress 4, so GR-R03
rejected it, rounds never completed, and throughput was computed for
rounds that never happened. The bench now asserts the per-round event
count and panics rather than measuring a stalled loop. The corrected
figure is 5.6x lower.
The evidence file states plainly what the boardgame.io comparison does
and does not support: the ~450x command-rate ratio is cross-runtime and
cross-feature-set, so it is a direction, not a verdict, per the
InnerLoop parity-cap rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:09:14 +02:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod bench_shape {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|
|
|
|
|
|
|
|
|
|
|
/// AM-6 reports events/second; the evidence file converts that to
|
|
|
|
|
|
/// rounds and commands per second. Both divisors are pinned here so
|
|
|
|
|
|
/// a change to the workload cannot silently rescale the metric.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn synthetic_round_shape_is_pinned() {
|
|
|
|
|
|
let mut state = GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: 3,
|
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
|
patch: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
1,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut events = 0;
|
|
|
|
|
|
let mut commands = 0;
|
|
|
|
|
|
let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| {
|
|
|
|
|
|
commands += 1;
|
|
|
|
|
|
if let Ok(produced) = state.validate(actor, cmd) {
|
|
|
|
|
|
for e in &produced {
|
|
|
|
|
|
state.fold(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
events += produced.len();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (seat, action, target) in [
|
|
|
|
|
|
(0u8, Action::Attack, Some(PlayerId(1))),
|
|
|
|
|
|
(2, Action::Support, Some(PlayerId(1))),
|
|
|
|
|
|
(1, Action::Ground, None),
|
|
|
|
|
|
] {
|
|
|
|
|
|
run(
|
|
|
|
|
|
&mut state,
|
|
|
|
|
|
Actor::Player(PlayerId(seat)),
|
|
|
|
|
|
&GroundCommand::SelectAction {
|
|
|
|
|
|
action,
|
|
|
|
|
|
target,
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
run(&mut state, Actor::System, &GroundCommand::Reveal);
|
|
|
|
|
|
run(
|
|
|
|
|
|
&mut state,
|
|
|
|
|
|
Actor::Player(PlayerId(1)),
|
|
|
|
|
|
&GroundCommand::ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode::Gr,
|
|
|
|
|
|
choice: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
run(&mut state, Actor::System, &GroundCommand::Resolve);
|
|
|
|
|
|
run(&mut state, Actor::System, &GroundCommand::EndRound);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(commands, 7, "commands per synthetic round");
|
|
|
|
|
|
assert_eq!(events, 13, "events per synthetic round");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod replay_probe {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use cb_events::state_hash_hex;
|
|
|
|
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|
|
|
|
|
use std::time::Instant;
|
|
|
|
|
|
|
|
|
|
|
|
fn fresh(seed: u64) -> GroundState {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: 3,
|
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
|
patch: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
seed,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn record_round(state: &mut GroundState, log: &mut Vec<GroundEvent>) -> usize {
|
|
|
|
|
|
let mut n = 0;
|
|
|
|
|
|
let mut run = |state: &mut GroundState, actor: Actor, cmd: &GroundCommand| {
|
|
|
|
|
|
if let Ok(produced) = state.validate(actor, cmd) {
|
|
|
|
|
|
for e in &produced {
|
|
|
|
|
|
state.fold(e);
|
|
|
|
|
|
log.push(e.clone());
|
|
|
|
|
|
n += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
for (seat, action, target) in [
|
|
|
|
|
|
(0u8, Action::Attack, Some(PlayerId(1))),
|
|
|
|
|
|
(2, Action::Support, Some(PlayerId(1))),
|
|
|
|
|
|
(1, Action::Ground, None),
|
|
|
|
|
|
] {
|
|
|
|
|
|
run(
|
|
|
|
|
|
state,
|
|
|
|
|
|
Actor::Player(PlayerId(seat)),
|
|
|
|
|
|
&GroundCommand::SelectAction {
|
|
|
|
|
|
action,
|
|
|
|
|
|
target,
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
run(state, Actor::System, &GroundCommand::Reveal);
|
|
|
|
|
|
run(
|
|
|
|
|
|
state,
|
|
|
|
|
|
Actor::Player(PlayerId(1)),
|
|
|
|
|
|
&GroundCommand::ChooseGroundMode {
|
|
|
|
|
|
mode: GroundMode::Gr,
|
|
|
|
|
|
choice: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
run(state, Actor::System, &GroundCommand::Resolve);
|
|
|
|
|
|
run(state, Actor::System, &GroundCommand::EndRound);
|
|
|
|
|
|
n
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// AM-7: folding a 100k-event log back into state must stay well
|
|
|
|
|
|
/// under the 5s budget, and must be linear in log length.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn replay_100k_events_is_linear_and_fast() {
|
|
|
|
|
|
for target in [10_000usize, 100_000] {
|
|
|
|
|
|
let mut log = Vec::with_capacity(target);
|
|
|
|
|
|
let mut source = fresh(42);
|
|
|
|
|
|
let mut games = 0u64;
|
|
|
|
|
|
let mut stalls = 0;
|
|
|
|
|
|
while log.len() < target {
|
|
|
|
|
|
if source.outcome.is_some() {
|
|
|
|
|
|
games += 1;
|
|
|
|
|
|
source = fresh(42 + games);
|
|
|
|
|
|
}
|
|
|
|
|
|
if record_round(&mut source, &mut log) == 0 {
|
|
|
|
|
|
stalls += 1;
|
|
|
|
|
|
assert!(stalls < 10, "round produced no events; builder stalled");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
|
let mut state = fresh(42);
|
|
|
|
|
|
for event in &log {
|
|
|
|
|
|
state.fold(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
let hash = state_hash_hex(&state);
|
|
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"replay {} events in {:?} ({:.0} events/s), hash {}",
|
|
|
|
|
|
log.len(),
|
|
|
|
|
|
elapsed,
|
|
|
|
|
|
log.len() as f64 / elapsed.as_secs_f64(),
|
|
|
|
|
|
&hash[..8]
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(elapsed.as_secs_f64() < 5.0, "AM-7: 100k replay under 5s");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|