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>
This commit is contained in:
tegwick 2026-07-31 02:23:09 +02:00
parent b58a9139aa
commit 27b7fe4329
10 changed files with 417 additions and 13 deletions

View file

@ -132,6 +132,35 @@ pub struct GroundState {
pub step: RoundStep,
/// GR-R02: face-down selections, hidden until Reveal.
pub selections: BTreeMap<PlayerId, Selection>,
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
/// state, keeping `validate` deterministic without holding RNG state.
pub seed: u64,
}
/// 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,
}
impl GroundMode {
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"GR" => Ok(GroundMode::Gr),
"OU" => Ok(GroundMode::Ou),
"ND" => Ok(GroundMode::Nd),
other => Err(format!(
"unknown GROUND mode {other:?} (expected GR, OU or ND)"
)),
}
}
}
/// GR-R01: Select → Reveal → Resolve → End.
@ -202,6 +231,9 @@ pub enum GroundCommand {
},
/// GR-R03: spend the READY Freedom token to bypass the stress gate.
SpendFreedom,
/// GR-R05: a player who revealed GROUND chooses its mode after
/// seeing all revealed Actions.
ChooseGroundMode { mode: GroundMode },
/// GR-R04: reveal all selections simultaneously. System-driven.
Reveal,
/// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
@ -248,6 +280,36 @@ pub enum GroundEvent {
attacker: PlayerId,
target: PlayerId,
},
/// GR-R05.
GroundModeChosen {
player: PlayerId,
mode: GroundMode,
},
/// GR-A01: a hidden Problem was turned face up.
ProblemRevealed {
problem: u32,
},
/// GR-A01: one Solution drawn from the deck.
SolutionDrawn {
player: PlayerId,
card: SolutionCard,
},
/// GR-A02: a matching Solution was spent to claim a Problem.
SolutionDiscarded {
player: PlayerId,
card: SolutionCard,
},
/// GR-A02.
ProblemClaimed {
problem: u32,
by: PlayerId,
},
/// GR-A01 under the U4 default: the discard was reshuffled into the
/// deck. The resulting order travels in the event, so `fold` stays
/// deterministic without replaying the RNG.
DeckReshuffled {
order: Vec<SolutionCard>,
},
/// GR-D01: Stress 5 at End with the marker OFF.
DarvoTriggered {
player: PlayerId,
@ -344,6 +406,21 @@ impl Aggregate for GroundState {
if self.step != RoundStep::Reveal || actor != Actor::System {
return Err(Rejection::NotAllowedNow);
}
// GR-R05: modes are chosen before resolution begins.
let pending: Vec<PlayerId> = self
.selections
.iter()
.filter(|(seat, sel)| {
sel.action == Action::Ground && !self.ground_modes.contains_key(seat)
})
.map(|(seat, _)| *seat)
.collect();
if !pending.is_empty() {
return Err(Rejection::Game {
code: "ground-mode-pending".into(),
detail: format!("GR-R05: no GROUND mode chosen for {pending:?}"),
});
}
return Ok(self.resolution_events());
}
GroundCommand::EndRound => {
@ -403,6 +480,31 @@ impl Aggregate for GroundState {
}
Ok(vec![GroundEvent::FreedomSpent { player: id }])
}
// GR-R05: only after Reveal, only for a player who revealed
// GROUND, once.
GroundCommand::ChooseGroundMode { mode } => {
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;
Ok(vec![GroundEvent::GroundModeChosen {
player: id,
mode: *mode,
}])
}
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
unreachable!("system commands are handled above")
}
@ -442,6 +544,38 @@ impl Aggregate for GroundState {
state.protection = state.protection.saturating_sub(1);
}
}
GroundEvent::GroundModeChosen { player, mode } => {
self.ground_modes.insert(*player, *mode);
}
GroundEvent::ProblemRevealed { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
state.face_up = true;
}
}
GroundEvent::SolutionDrawn { player, card } => {
// The deck draws from its end, matching the GR-S02 deal.
self.solution_deck.pop();
if let Some(state) = self.players.get_mut(player) {
state.hand.push(*card);
}
}
GroundEvent::SolutionDiscarded { player, card } => {
if let Some(state) = self.players.get_mut(player) {
if let Some(pos) = state.hand.iter().position(|c| c == card) {
state.hand.remove(pos);
}
}
self.solution_discard.push(*card);
}
GroundEvent::ProblemClaimed { problem, by } => {
if let Some(state) = self.problems.get_mut(problem) {
state.claimed_by = Some(*by);
}
}
GroundEvent::DeckReshuffled { order } => {
self.solution_deck = order.clone();
self.solution_discard.clear();
}
GroundEvent::DarvoTriggered { player } => {
if let Some(state) = self.players.get_mut(player) {
state.darvo = DarvoStage::Deny;
@ -454,6 +588,7 @@ impl Aggregate for GroundState {
self.round = *round;
self.lead = *next_lead;
self.selections.clear();
self.ground_modes.clear();
for state in self.players.values_mut() {
// GR-R03: the gate lift lasts one Select step only.
state.freedom_gate_lifted = false;
@ -470,9 +605,10 @@ impl Aggregate for GroundState {
impl GroundState {
/// GR-R06/R07: resolve in fixed step order, Lead first within a step.
///
/// Steps 1 (GROUND), 3 (DARVO stages), 4 (INVESTIGATE) and 6 (SOLVE)
/// are not yet implemented — their Actions resolve as no-ops and no
/// scenario claims coverage of them.
/// Step 3 (active DARVO stages) is not yet implemented — the DARVO
/// machine lands with GR-D02..D07 and no scenario claims coverage of
/// it. GROUND—OU and GROUND—ND (GR-A11/A12) each offer a three-way
/// choice that needs its own command and are likewise pending.
fn resolution_events(&self) -> Vec<GroundEvent> {
let mut events = Vec::new();
// A working copy so slot counts and Stress reflect earlier
@ -483,6 +619,30 @@ impl GroundState {
// (GR-L05): a Bond formed now is not "pre-existing".
let pre_existing = self.relations.clone();
// Step 1 — GROUND (GR-A10..A12).
for actor in self.seat_order() {
if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) {
continue;
}
match self.ground_modes.get(&actor) {
// GR-A10: Ground & Restate — self 2 Stress, Freedom READY.
Some(GroundMode::Gr) => {
let stress = work.stress_after(actor, -2);
events.push(GroundEvent::StressSet {
player: actor,
stress,
});
work.fold(events.last().expect("just pushed"));
if !work.players[&actor].freedom_ready {
events.push(GroundEvent::FreedomReadied { player: actor });
work.fold(events.last().expect("just pushed"));
}
}
// GR-A11/A12 need a sub-choice command; pending.
Some(GroundMode::Ou) | Some(GroundMode::Nd) | None => {}
}
}
// Step 2 — Support (GR-A03/A04/A05).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
@ -602,12 +762,94 @@ impl GroundState {
}
}
// Step 4 — INVESTIGATE (GR-A01).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Investigate {
continue;
}
// Reveal the chosen Problem if it is still hidden and not
// Denied; otherwise the draw happens on its own.
if let Some(problem) = selection.problem {
let eligible = work
.problems
.get(&problem)
.is_some_and(|p| !p.face_up && !p.denied);
if eligible {
events.push(GroundEvent::ProblemRevealed { problem });
work.fold(events.last().expect("just pushed"));
}
}
work.draw_solution(actor, &mut events);
}
// Step 6 — SOLVE (GR-A02).
for actor in self.seat_order() {
let Some(selection) = self.selections.get(&actor) else {
continue;
};
if selection.action != Action::Solve {
continue;
}
let Some(problem) = selection.problem else {
continue;
};
let Some(target) = work.problems.get(&problem) else {
continue;
};
// GR-A02: an earlier resolver this round already claimed it,
// so no Solution is spent and nothing happens.
if target.claimed_by.is_some() || target.denied || !target.face_up {
continue;
}
let required = target.suit;
let Some(card) = work.players[&actor]
.hand
.iter()
.find(|c| c.suit == required)
.copied()
else {
continue;
};
events.push(GroundEvent::SolutionDiscarded {
player: actor,
card,
});
work.fold(events.last().expect("just pushed"));
events.push(GroundEvent::ProblemClaimed { problem, by: actor });
work.fold(events.last().expect("just pushed"));
}
events.push(GroundEvent::StepAdvanced {
step: RoundStep::Resolve,
});
events
}
/// GR-A01: draw one Solution, reshuffling the discard first if the
/// deck is empty (the U4 default). The reshuffled order travels in
/// the event so replay never re-derives it.
fn draw_solution(&mut self, player: PlayerId, events: &mut Vec<GroundEvent>) {
if self.solution_deck.is_empty() {
if self.solution_discard.is_empty() {
return;
}
let mut order = self.solution_discard.clone();
// Derived from the game seed and round, so the reshuffle is
// a pure function of state (GameKernel K5).
let mut rng = ChaChaRng::from_seed(Seed(self.seed ^ u64::from(self.round)));
rng.shuffle(&mut order);
events.push(GroundEvent::DeckReshuffled { order });
self.fold(events.last().expect("just pushed"));
}
if let Some(card) = self.solution_deck.last().copied() {
events.push(GroundEvent::SolutionDrawn { player, card });
self.fold(events.last().expect("just pushed"));
}
}
/// GR-R08: Stress is already clamped on every application (U2), so
/// End only triggers DARVO, rotates the Lead, and advances the Round.
fn end_round_events(&self) -> Vec<GroundEvent> {
@ -668,8 +910,22 @@ impl GroundState {
if action.requires_problem_target() {
let problem =
problem.ok_or_else(|| bad(format!("GR-A13: {action:?} needs a Problem")))?;
if !self.problems.contains_key(&problem) {
return Err(bad(format!("GR-A13: no Problem {problem}")));
let target = self
.problems
.get(&problem)
.ok_or_else(|| bad(format!("GR-A13: no Problem {problem}")))?;
match action {
// GR-A13: INVESTIGATE targets a hidden Problem.
Action::Investigate if target.face_up => {
return Err(bad(format!("GR-A13: Problem {problem} is already face up")));
}
// GR-A13: SOLVE targets a face-up, non-Denied Problem.
Action::Solve if !target.face_up || target.denied => {
return Err(bad(format!(
"GR-A13: Problem {problem} is not a face-up, non-Denied Problem"
)));
}
_ => {}
}
} else if problem.is_some() {
return Err(bad(format!("GR-A13: {action:?} takes no Problem target")));
@ -770,6 +1026,8 @@ impl ScenarioGame for GroundState {
focus: BTreeMap::new(),
step: RoundStep::Select,
selections: BTreeMap::new(),
ground_modes: BTreeMap::new(),
seed,
})
}
@ -800,6 +1058,9 @@ impl ScenarioGame for GroundState {
}
}
"spend_freedom" => GroundCommand::SpendFreedom,
"choose_ground_mode" => GroundCommand::ChooseGroundMode {
mode: GroundMode::parse(&arg_str("mode")?)?,
},
"reveal" => GroundCommand::Reveal,
"resolve" => GroundCommand::Resolve,
"end_round" => GroundCommand::EndRound,
@ -841,6 +1102,8 @@ mod tests {
focus: BTreeMap::new(),
step: RoundStep::Select,
selections: BTreeMap::new(),
ground_modes: BTreeMap::new(),
seed: 0,
}
}

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a01-investigate
description: >
INVESTIGATE reveals the chosen hidden Problem and draws one Solution
(GR-A01). Selecting an already face-up Problem is rejected (GR-A13).
covers: [GR-A01, GR-S01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
commands:
- actor: P1
cmd: select_action
args: { action: INVESTIGATE, problem: 2 }
# GR-A13: Problem 1 is the Surface Problem, already face up.
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 1 }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: ProblemRevealed
problem: 2
- kind: SolutionDrawn
player: 0
state:
"problems.2.face_up": true
"problems.3.face_up": true
# GR-S02 dealt two; INVESTIGATE drew a third. The suit is the
# golden value for seed 42 under the GR-S04 shuffle.
"players.0.hand.2.suit": Change
rejects: [1]

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a02-solve
description: >
SOLVE discards a Solution of the Problem's suit and claims it
(GR-A02). A second solver of the same Problem the same round spends
nothing, because an earlier resolver in Lead order already claimed it.
covers: [GR-A02, GR-R07, GR-O04]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.hand": [{ suit: Clarify }]
"players.1.hand": [{ suit: Clarify }]
commands:
- actor: P1
cmd: select_action
args: { action: SOLVE, problem: 1 }
- actor: P2
cmd: select_action
args: { action: SOLVE, problem: 1 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: SolutionDiscarded
player: 0
- kind: ProblemClaimed
problem: 1
by: 0
state:
# Problem 1 is the Clarify Surface Problem (GR-S01).
"problems.1.claimed_by": 0
"players.0.hand": []
# GR-A02: P2 resolved second, so its Solution is not spent.
"players.1.hand": [{ suit: Clarify }]
rejects: []

View file

@ -24,7 +24,7 @@ commands:
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM

View file

@ -17,10 +17,10 @@ commands:
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM

View file

@ -22,7 +22,7 @@ commands:
args: { action: ATTACK, target: P3 }
- actor: P3
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM

View file

@ -0,0 +1,55 @@
scenario: ground/gr-a10-ground-gr
description: >
GROUND—GR (Ground & Restate): the mode is chosen after Reveal
(GR-R05), and on resolution the player takes 2 Stress and readies
their Freedom token (GR-A10, GR-F04). Resolution refuses to start
while any revealed GROUND still lacks a mode.
covers: [GR-R05, GR-A10]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.stress": 5
"players.0.freedom_ready": false
commands:
- actor: P1
cmd: select_action
args: { action: GROUND }
- actor: P2
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-R05: no mode chosen yet, so resolution is refused.
- actor: SYSTEM
cmd: resolve
# GR-R05: only a player who revealed GROUND may choose a mode.
- actor: P2
cmd: choose_ground_mode
args: { mode: GR }
- actor: P1
cmd: choose_ground_mode
args: { mode: GR }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: GroundModeChosen
player: 0
mode: Gr
- kind: StressSet
player: 0
stress: 3
- kind: FreedomReadied
player: 0
state:
"players.0.stress": 3
"players.0.freedom_ready": true
"step": Resolve
rejects: [4, 5]

View file

@ -18,10 +18,10 @@ commands:
args: { action: ATTACK, target: P2 }
- actor: P2
cmd: select_action
args: { action: GROUND }
args: { action: ATTACK, target: P3 }
- actor: P3
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM

View file

@ -21,7 +21,7 @@ commands:
# GR-R02: one selection per player per round.
- actor: P1
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
expect:
events:
- kind: ActionSelected

View file

@ -20,7 +20,7 @@ commands:
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: GROUND }
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
- actor: SYSTEM