From 0e92535f113c5eedb528d85140a08446df1acdb3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 31 Jul 2026 02:30:58 +0200 Subject: [PATCH] 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 --- games/ground/src/lib.rs | 376 ++++++++++++++++--- scenarios/ground/gr-d03-darvo-deny.yaml | 48 +++ scenarios/ground/gr-d04-darvo-attack.yaml | 53 +++ scenarios/ground/gr-d05-darvo-reverse.yaml | 54 +++ scenarios/ground/gr-d06-darvo-early-end.yaml | 43 +++ 5 files changed, 513 insertions(+), 61 deletions(-) create mode 100644 scenarios/ground/gr-d03-darvo-deny.yaml create mode 100644 scenarios/ground/gr-d04-darvo-attack.yaml create mode 100644 scenarios/ground/gr-d05-darvo-reverse.yaml create mode 100644 scenarios/ground/gr-d06-darvo-early-end.yaml diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 8245d93..6b7bc6a 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -138,11 +138,22 @@ pub struct GroundState { pub ground_choices: BTreeMap, /// GR-L02/A05: a Support target's response, keyed by target. pub support_responses: BTreeMap, + /// GR-D03/D04: the mandatory target a DARVO stage needs this round. + pub darvo_targets: 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-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, + pub player: Option, +} + /// GR-A10..A12: the three GROUND modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum GroundMode { @@ -326,6 +337,8 @@ pub enum GroundCommand { }, /// GR-L02/A05: respond to a Support aimed at this player. RespondToSupport { response: SupportResponse }, + /// GR-D03/D04: name the mandatory target of this round's stage. + ChooseDarvoTarget { target: DarvoTarget }, /// GR-R04: reveal all selections simultaneously. System-driven. Reveal, /// GR-R06/R07: resolve revealed Actions in fixed step order, Lead @@ -425,6 +438,38 @@ pub enum GroundEvent { DarvoTriggered { player: PlayerId, }, + /// GR-D03/D04. + DarvoTargetChosen { + player: PlayerId, + target: DarvoTarget, + }, + /// GR-D03: a Problem was turned face down and Denied. + ProblemDenied { + problem: u32, + }, + /// GR-D04/T03: the sequence owner's Focus token was placed. + FocusPlaced { + owner: PlayerId, + target: PlayerId, + }, + /// GR-D05/T03: Focus flipped to Blame in front of the target. + FocusFlippedToBlame { + owner: PlayerId, + target: PlayerId, + }, + /// GR-D05/T01. + ProtectionGained { + player: PlayerId, + }, + /// GR-D02: the sequence moved to its next stage. + DarvoAdvanced { + player: PlayerId, + stage: DarvoStage, + }, + /// GR-D05/D06/D07: the sequence ended and the marker is OFF again. + DarvoEnded { + player: PlayerId, + }, /// GR-R01: the round advanced to `step`. StepAdvanced { step: RoundStep, @@ -679,6 +724,64 @@ impl Aggregate for GroundState { response: *response, }]) } + // GR-D03/D04: only a player with a live sequence, after + // Reveal, once per round. + GroundCommand::ChooseDarvoTarget { target } => { + if self.step != RoundStep::Reveal { + return Err(Rejection::NotAllowedNow); + } + if player.darvo == DarvoStage::Off { + return Err(Rejection::Game { + code: "no-darvo-sequence".into(), + detail: "GR-D02: this player has no live DARVO sequence".into(), + }); + } + if self.darvo_targets.contains_key(&id) { + return Err(Rejection::DuplicateCommand); + } + match player.darvo { + // GR-D03: a face-up, unsolved, unprotected Problem. + DarvoStage::Deny => { + let problem = target.problem.ok_or(Rejection::Game { + code: "bad-darvo-target".into(), + detail: "GR-D03: DENY names a Problem".into(), + })?; + let eligible = self.problems.get(&problem).is_some_and(|p| { + p.face_up + && !p.denied + && p.claimed_by.is_none() + && !p.protected_this_round + }); + if !eligible { + return Err(Rejection::Game { + code: "bad-darvo-target".into(), + detail: format!( + "GR-D03: Problem {problem} is not face-up, unsolved and unprotected" + ), + }); + } + } + // GR-D04: an extra Attack against another player. + DarvoStage::Attack => { + let other = target.player.ok_or(Rejection::Game { + code: "bad-darvo-target".into(), + detail: "GR-D04: ATTACK names a player".into(), + })?; + if other == id || !self.players.contains_key(&other) { + return Err(Rejection::Game { + code: "bad-darvo-target".into(), + detail: "GR-D04: ATTACK targets another player".into(), + }); + } + } + // GR-D05: REVERSE uses the placed Focus token. + DarvoStage::Reverse | DarvoStage::Off => {} + } + Ok(vec![GroundEvent::DarvoTargetChosen { + player: id, + target: *target, + }]) + } GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => { unreachable!("system commands are handled above") } @@ -783,6 +886,41 @@ impl Aggregate for GroundState { state.darvo = DarvoStage::Deny; } } + GroundEvent::DarvoTargetChosen { player, target } => { + self.darvo_targets.insert(*player, *target); + } + GroundEvent::ProblemDenied { problem } => { + if let Some(state) = self.problems.get_mut(problem) { + state.denied = true; + state.face_up = false; + } + } + GroundEvent::FocusPlaced { owner, target } => { + self.focus.insert(*owner, *target); + } + GroundEvent::FocusFlippedToBlame { owner, target } => { + self.focus.remove(owner); + if let Some(state) = self.players.get_mut(target) { + state.blame_from.push(*owner); + } + } + GroundEvent::ProtectionGained { player } => { + if let Some(state) = self.players.get_mut(player) { + state.protection = state.protection.saturating_add(1); + } + } + GroundEvent::DarvoAdvanced { player, stage } => { + if let Some(state) = self.players.get_mut(player) { + state.darvo = *stage; + } + } + GroundEvent::DarvoEnded { player } => { + if let Some(state) = self.players.get_mut(player) { + state.darvo = DarvoStage::Off; + } + // GR-D06: an unresolved Focus token comes back. + self.focus.remove(player); + } GroundEvent::StepAdvanced { step } => { self.step = *step; } @@ -793,6 +931,7 @@ impl Aggregate for GroundState { self.ground_modes.clear(); self.ground_choices.clear(); self.support_responses.clear(); + self.darvo_targets.clear(); for state in self.players.values_mut() { // GR-R03: the gate lift lasts one Select step only. state.freedom_gate_lifted = false; @@ -958,6 +1097,106 @@ impl GroundState { } } + // Step 3 — active DARVO stages (GR-D02..D07). + for owner in self.seat_order() { + let stage = work.players[&owner].darvo; + if stage == DarvoStage::Off { + continue; + } + + // GR-D06 + GR-A04: a Support through a Bond that existed + // before this round's Support step cancels the current stage + // and ends the sequence (GR-L05). + let bond_support = self.selections.iter().any(|(seat, sel)| { + sel.action == Action::Support + && sel.target == Some(owner) + && pre_existing.get(&Pair::new(*seat, owner)) == Some(&Relation::Bond) + }); + if bond_support { + events.push(GroundEvent::DarvoEnded { player: owner }); + work.fold(events.last().expect("just pushed")); + continue; + } + + match stage { + // GR-D03: turn one eligible Problem face down and Deny + // it. Under the U3 default, no legal target is a no-op + // and the sequence still advances. + DarvoStage::Deny => { + if let Some(problem) = work.darvo_targets.get(&owner).and_then(|t| t.problem) { + let eligible = work.problems.get(&problem).is_some_and(|p| { + p.face_up + && !p.denied + && p.claimed_by.is_none() + && !p.protected_this_round + }); + if eligible { + events.push(GroundEvent::ProblemDenied { problem }); + work.fold(events.last().expect("just pushed")); + } + } + } + // GR-D04: one extra Attack under the normal relation + // rules, then place the Focus token beside the target — + // even if the Attack was cancelled. + DarvoStage::Attack => { + if let Some(target) = work.darvo_targets.get(&owner).and_then(|t| t.player) { + work.resolve_attack(owner, target, &ou_cancels, &mut events); + events.push(GroundEvent::FocusPlaced { owner, target }); + work.fold(events.last().expect("just pushed")); + } + } + // GR-D05: targets the Focus holder. + DarvoStage::Reverse => { + if let Some(target) = work.focus.get(&owner).copied() { + // GR-A12: the target's GROUND—ND may reject it. + let rejected = + work.ground_choices.get(&target) == Some(&GroundChoice::RejectReverse); + if !rejected { + events.push(GroundEvent::FocusFlippedToBlame { owner, target }); + work.fold(events.last().expect("just pushed")); + let stress = work.stress_after(target, 1); + events.push(GroundEvent::StressSet { + player: target, + stress, + }); + work.fold(events.last().expect("just pushed")); + events.push(GroundEvent::ProtectionGained { player: owner }); + work.fold(events.last().expect("just pushed")); + } + // U5: rejected or not, the owner still takes −2 + // and the sequence ends. + let stress = work.stress_after(owner, -2); + events.push(GroundEvent::StressSet { + player: owner, + stress, + }); + work.fold(events.last().expect("just pushed")); + } + } + DarvoStage::Off => {} + } + + // GR-A10 + GR-D06: GROUND—GR ends the sequence after the + // current stage resolves. GR-D05: REVERSE ends it anyway. + let ground_gr = work.ground_modes.get(&owner) == Some(&GroundMode::Gr); + if ground_gr || stage == DarvoStage::Reverse { + events.push(GroundEvent::DarvoEnded { player: owner }); + work.fold(events.last().expect("just pushed")); + } else { + // GR-D02: one stage per consecutive round. + let next = match stage { + DarvoStage::Deny => DarvoStage::Attack, + _ => DarvoStage::Reverse, + }; + events.push(GroundEvent::DarvoAdvanced { + player: owner, + stage: next, + }); + work.fold(events.last().expect("just pushed")); + } + } + // Step 5 — Attack (GR-A06..A09). for actor in self.seat_order() { let Some(selection) = self.selections.get(&actor) else { @@ -970,67 +1209,7 @@ impl GroundState { continue; }; - // 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(&(actor, target)) { - continue; - } - // GR-A09/T01: Protection absorbs the Attack entirely. - if work.players[&target].protection > 0 { - events.push(GroundEvent::AttackCancelled { - attacker: actor, - target, - }); - work.fold(events.last().expect("just pushed")); - continue; - } - - let pair = Pair::new(actor, target); - match work.relation_between(actor, target) { - // GR-A07: Attack through a Bond — +2 and the Bond flips. - Some(Relation::Bond) => { - let stress = work.stress_after(target, 2); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - events.push(GroundEvent::RelationFormed { - pair, - relation: Relation::Rivalry, - }); - work.fold(events.last().expect("just pushed")); - } - // GR-A08: Attack through a Rivalry — +2 and it breaks. - Some(Relation::Rivalry) => { - let stress = work.stress_after(target, 2); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - events.push(GroundEvent::RelationBroken { pair }); - work.fold(events.last().expect("just pushed")); - } - // GR-A06: no relation — +1, and a Rivalry forms without - // consent if both endpoints have a slot (GR-L01/L03). - None => { - let stress = work.stress_after(target, 1); - events.push(GroundEvent::StressSet { - player: target, - stress, - }); - work.fold(events.last().expect("just pushed")); - if work.has_free_slot(actor) && work.has_free_slot(target) { - events.push(GroundEvent::RelationFormed { - pair, - relation: Relation::Rivalry, - }); - work.fold(events.last().expect("just pushed")); - } - } - } + work.resolve_attack(actor, target, &ou_cancels, &mut events); } // Step 4 — INVESTIGATE (GR-A01). @@ -1099,6 +1278,67 @@ impl GroundState { events } + /// GR-A06..A09: one Attack under the normal relation rules. Shared + /// by the chosen ATTACK Action (step 5) and the DARVO ATTACK stage's + /// extra Attack (GR-D04). + fn resolve_attack( + &mut self, + attacker: PlayerId, + target: PlayerId, + ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>, + events: &mut Vec, + ) { + // 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")); + } + } + /// 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. @@ -1367,6 +1607,7 @@ impl ScenarioGame for GroundState { ground_modes: BTreeMap::new(), ground_choices: BTreeMap::new(), support_responses: BTreeMap::new(), + darvo_targets: BTreeMap::new(), seed, }) } @@ -1408,6 +1649,18 @@ impl ScenarioGame for GroundState { None => None, }, }, + "choose_darvo_target" => GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: arg_u64("problem").map(|p| p as u32), + player: match step.args.get("target") { + Some(_) => match parse_actor(&arg_str("target")?)? { + Actor::Player(seat) => Some(seat), + Actor::System => return Err("target may not be SYSTEM".into()), + }, + None => None, + }, + }, + }, "respond_to_support" => GroundCommand::RespondToSupport { response: SupportResponse::parse(&arg_str("response")?)?, }, @@ -1455,6 +1708,7 @@ mod tests { ground_modes: BTreeMap::new(), ground_choices: BTreeMap::new(), support_responses: BTreeMap::new(), + darvo_targets: BTreeMap::new(), seed: 0, } } diff --git a/scenarios/ground/gr-d03-darvo-deny.yaml b/scenarios/ground/gr-d03-darvo-deny.yaml new file mode 100644 index 0000000..2ca341a --- /dev/null +++ b/scenarios/ground/gr-d03-darvo-deny.yaml @@ -0,0 +1,48 @@ +scenario: ground/gr-d03-darvo-deny +description: > + The DENY stage turns one face-up, unsolved, unprotected Problem face + down and Denies it (GR-D03), then the sequence advances to ATTACK + (GR-D02). A protected Problem is not a legal DENY target (GR-A11). +covers: [GR-D02, GR-D03, GR-P01] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "players.0.darvo": Deny + "problems.2.face_up": true +commands: + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - 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-D02: only a player with a live sequence names a stage target. + - actor: P2 + cmd: choose_darvo_target + args: { problem: 1 } + - actor: P1 + cmd: choose_darvo_target + args: { problem: 1 } + - actor: SYSTEM + cmd: resolve +expect: + events: + - kind: ProblemDenied + problem: 1 + - kind: DarvoAdvanced + player: 0 + stage: Attack + state: + "problems.1.denied": true + "problems.1.face_up": false + "players.0.darvo": Attack + rejects: [4] diff --git a/scenarios/ground/gr-d04-darvo-attack.yaml b/scenarios/ground/gr-d04-darvo-attack.yaml new file mode 100644 index 0000000..d6e12f8 --- /dev/null +++ b/scenarios/ground/gr-d04-darvo-attack.yaml @@ -0,0 +1,53 @@ +scenario: ground/gr-d04-darvo-attack +description: > + The ATTACK stage makes one extra Attack under the normal relation + rules and places the Focus token beside the target (GR-D04), then + advances to REVERSE. +covers: [GR-D04, GR-T03] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "players.0.darvo": Attack +commands: + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - 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-D04: the extra Attack targets another player. + - actor: P1 + cmd: choose_darvo_target + args: { target: P1 } + - actor: P1 + cmd: choose_darvo_target + args: { target: P2 } + - actor: SYSTEM + cmd: resolve +expect: + events: + - kind: StressSet + player: 1 + stress: 3 + - kind: FocusPlaced + owner: 0 + target: 1 + - kind: DarvoAdvanced + player: 0 + stage: Reverse + state: + "players.1.stress": 3 + "focus.0": 1 + "players.0.darvo": Reverse + # GR-L03: the extra Attack forms a Rivalry like any other. + "relations.0-1": Rivalry + rejects: [4] diff --git a/scenarios/ground/gr-d05-darvo-reverse.yaml b/scenarios/ground/gr-d05-darvo-reverse.yaml new file mode 100644 index 0000000..77efb18 --- /dev/null +++ b/scenarios/ground/gr-d05-darvo-reverse.yaml @@ -0,0 +1,54 @@ +scenario: ground/gr-d05-darvo-reverse +description: > + The REVERSE stage flips Focus to Blame in front of the Focus holder, + gives them +1 Stress and the owner one Protection token, then the + owner takes −2 Stress and the sequence ends (GR-D05, GR-D07). +covers: [GR-D05, GR-D07, GR-T01] +provisional: true +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "players.0.darvo": Reverse + "players.0.stress": 3 + "focus.0": 1 +commands: + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - 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: FocusFlippedToBlame + owner: 0 + target: 1 + - kind: StressSet + player: 1 + stress: 3 + - kind: ProtectionGained + player: 0 + - kind: StressSet + player: 0 + stress: 1 + - kind: DarvoEnded + player: 0 + state: + "players.1.blame_from": [0] + "players.1.stress": 3 + "players.0.protection": 1 + "players.0.stress": 1 + # GR-D07: the marker is OFF, so a new sequence can trigger later. + "players.0.darvo": Off + "focus": {} + rejects: [] diff --git a/scenarios/ground/gr-d06-darvo-early-end.yaml b/scenarios/ground/gr-d06-darvo-early-end.yaml new file mode 100644 index 0000000..a5dd95a --- /dev/null +++ b/scenarios/ground/gr-d06-darvo-early-end.yaml @@ -0,0 +1,43 @@ +scenario: ground/gr-d06-darvo-early-end +description: > + A Support through a Bond that existed before this round cancels the + current stage and ends the sequence; the placed Focus token is removed + (GR-D06, GR-A04, GR-L05). +covers: [GR-D06, GR-L05] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 0 + "players.0.darvo": Attack + "focus.0": 2 + "relations.0-1": Bond +commands: + - actor: P2 + cmd: select_action + args: { action: SUPPORT, target: P1 } + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - actor: P3 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - actor: SYSTEM + cmd: reveal + - actor: P1 + cmd: choose_darvo_target + args: { target: P3 } + - actor: SYSTEM + cmd: resolve +expect: + events: + - kind: DarvoEnded + player: 0 + state: + "players.0.darvo": Off + # GR-D06: the stage never fired, so P3 took no extra Attack. + "players.2.stress": 2 + "focus": {} + rejects: []