From 27b7fe4329f0c234aa43be98ce0f5d8684a8431c Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 31 Jul 2026 02:23:09 +0200 Subject: [PATCH] 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 --- games/ground/src/lib.rs | 273 ++++++++++++++++++++- scenarios/ground/gr-a01-investigate.yaml | 43 ++++ scenarios/ground/gr-a02-solve.yaml | 43 ++++ scenarios/ground/gr-a04-bond-support.yaml | 2 +- scenarios/ground/gr-a07-bond-flip.yaml | 4 +- scenarios/ground/gr-a08-rivalry-break.yaml | 2 +- scenarios/ground/gr-a10-ground-gr.yaml | 55 +++++ scenarios/ground/gr-d01-darvo-trigger.yaml | 4 +- scenarios/ground/gr-r02-select.yaml | 2 +- scenarios/ground/gr-r06-round-resolve.yaml | 2 +- 10 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 scenarios/ground/gr-a01-investigate.yaml create mode 100644 scenarios/ground/gr-a02-solve.yaml create mode 100644 scenarios/ground/gr-a10-ground-gr.yaml diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 0a45117..7a12d96 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -132,6 +132,35 @@ pub struct GroundState { pub step: RoundStep, /// GR-R02: face-down selections, hidden until Reveal. pub selections: BTreeMap, + /// GR-R05: GROUND modes chosen after Reveal, before Resolve. + pub ground_modes: BTreeMap, + /// 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 { + 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, + }, /// 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 = 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 { 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) { + 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 { @@ -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, } } diff --git a/scenarios/ground/gr-a01-investigate.yaml b/scenarios/ground/gr-a01-investigate.yaml new file mode 100644 index 0000000..e5de082 --- /dev/null +++ b/scenarios/ground/gr-a01-investigate.yaml @@ -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] diff --git a/scenarios/ground/gr-a02-solve.yaml b/scenarios/ground/gr-a02-solve.yaml new file mode 100644 index 0000000..be56995 --- /dev/null +++ b/scenarios/ground/gr-a02-solve.yaml @@ -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: [] diff --git a/scenarios/ground/gr-a04-bond-support.yaml b/scenarios/ground/gr-a04-bond-support.yaml index c4f0443..d70d957 100644 --- a/scenarios/ground/gr-a04-bond-support.yaml +++ b/scenarios/ground/gr-a04-bond-support.yaml @@ -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 diff --git a/scenarios/ground/gr-a07-bond-flip.yaml b/scenarios/ground/gr-a07-bond-flip.yaml index 24b00c1..b90030b 100644 --- a/scenarios/ground/gr-a07-bond-flip.yaml +++ b/scenarios/ground/gr-a07-bond-flip.yaml @@ -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 diff --git a/scenarios/ground/gr-a08-rivalry-break.yaml b/scenarios/ground/gr-a08-rivalry-break.yaml index fa5e21a..2ea741f 100644 --- a/scenarios/ground/gr-a08-rivalry-break.yaml +++ b/scenarios/ground/gr-a08-rivalry-break.yaml @@ -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 diff --git a/scenarios/ground/gr-a10-ground-gr.yaml b/scenarios/ground/gr-a10-ground-gr.yaml new file mode 100644 index 0000000..bb93c1a --- /dev/null +++ b/scenarios/ground/gr-a10-ground-gr.yaml @@ -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] diff --git a/scenarios/ground/gr-d01-darvo-trigger.yaml b/scenarios/ground/gr-d01-darvo-trigger.yaml index 1b7bc13..19ed4ab 100644 --- a/scenarios/ground/gr-d01-darvo-trigger.yaml +++ b/scenarios/ground/gr-d01-darvo-trigger.yaml @@ -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 diff --git a/scenarios/ground/gr-r02-select.yaml b/scenarios/ground/gr-r02-select.yaml index 94b22bd..117d9c7 100644 --- a/scenarios/ground/gr-r02-select.yaml +++ b/scenarios/ground/gr-r02-select.yaml @@ -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 diff --git a/scenarios/ground/gr-r06-round-resolve.yaml b/scenarios/ground/gr-r06-round-resolve.yaml index de7f5cd..679a40a 100644 --- a/scenarios/ground/gr-r06-round-resolve.yaml +++ b/scenarios/ground/gr-r06-round-resolve.yaml @@ -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