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>
This commit is contained in:
tegwick 2026-07-31 02:30:58 +02:00
parent 290ad06c31
commit 0e92535f11
5 changed files with 513 additions and 61 deletions

View file

@ -138,11 +138,22 @@ pub struct GroundState {
pub ground_choices: BTreeMap<PlayerId, GroundChoice>,
/// GR-L02/A05: a Support target's response, keyed by target.
pub support_responses: BTreeMap<PlayerId, SupportResponse>,
/// GR-D03/D04: the mandatory target a DARVO stage needs this round.
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// GR-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<u32>,
pub player: Option<PlayerId>,
}
/// 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<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"));
}
}
/// 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,
}
}

View file

@ -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]

View file

@ -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]

View file

@ -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: []

View file

@ -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: []