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>
This commit is contained in:
tegwick 2026-07-31 02:27:25 +02:00
parent 27016af216
commit 290ad06c31
5 changed files with 512 additions and 13 deletions

View file

@ -1,6 +1,6 @@
---
active: true
iteration: 2
iteration: 3
session_id: 8cbd5701-a096-45a4-a419-9b7b1c9419bc
max_iterations: 20
completion_promise: "HEUREKA"

View file

@ -134,6 +134,10 @@ pub struct GroundState {
pub selections: BTreeMap<PlayerId, Selection>,
/// GR-R05: GROUND modes chosen after Reveal, before Resolve.
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
/// 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>,
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
/// state, keeping `validate` deterministic without holding RNG state.
pub seed: u64,
@ -150,6 +154,89 @@ pub enum GroundMode {
Nd,
}
/// 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,
}
}
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 {
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:?}")),
}
}
}
impl GroundMode {
fn parse(raw: &str) -> Result<Self, String> {
match raw {
@ -233,7 +320,12 @@ pub enum GroundCommand {
SpendFreedom,
/// GR-R05: a player who revealed GROUND chooses its mode after
/// seeing all revealed Actions.
ChooseGroundMode { mode: GroundMode },
ChooseGroundMode {
mode: GroundMode,
choice: Option<GroundChoice>,
},
/// GR-L02/A05: respond to a Support aimed at this player.
RespondToSupport { response: SupportResponse },
/// GR-R04: reveal all selections simultaneously. System-driven.
Reveal,
/// GR-R06/R07: resolve revealed Actions in fixed step order, Lead
@ -280,10 +372,29 @@ pub enum GroundEvent {
attacker: PlayerId,
target: PlayerId,
},
/// GR-R05.
/// GR-R05/A11/A12.
GroundModeChosen {
player: PlayerId,
mode: GroundMode,
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,
},
/// GR-A01: a hidden Problem was turned face up.
ProblemRevealed {
@ -482,7 +593,7 @@ impl Aggregate for GroundState {
}
// GR-R05: only after Reveal, only for a player who revealed
// GROUND, once.
GroundCommand::ChooseGroundMode { mode } => {
GroundCommand::ChooseGroundMode { mode, choice } => {
if self.step != RoundStep::Reveal {
return Err(Rejection::NotAllowedNow);
}
@ -500,9 +611,72 @@ impl Aggregate for GroundState {
return Err(Rejection::DuplicateCommand);
}
let _ = player;
// 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)?;
Ok(vec![GroundEvent::GroundModeChosen {
player: id,
mode: *mode,
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,
}])
}
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => {
@ -544,8 +718,36 @@ impl Aggregate for GroundState {
state.protection = state.protection.saturating_sub(1);
}
}
GroundEvent::GroundModeChosen { player, mode } => {
GroundEvent::GroundModeChosen {
player,
mode,
choice,
} => {
self.ground_modes.insert(*player, *mode);
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);
}
}
}
GroundEvent::ProblemRevealed { problem } => {
if let Some(state) = self.problems.get_mut(problem) {
@ -589,6 +791,8 @@ impl Aggregate for GroundState {
self.lead = *next_lead;
self.selections.clear();
self.ground_modes.clear();
self.ground_choices.clear();
self.support_responses.clear();
for state in self.players.values_mut() {
// GR-R03: the gate lift lasts one Select step only.
state.freedom_gate_lifted = false;
@ -619,6 +823,10 @@ impl GroundState {
// (GR-L05): a Bond formed now is not "pre-existing".
let pre_existing = self.relations.clone();
// GR-A11: Attacks cancelled by a GROUND—OU choice this round.
let mut ou_cancels: std::collections::BTreeSet<(PlayerId, PlayerId)> =
std::collections::BTreeSet::new();
// Step 1 — GROUND (GR-A10..A12).
for actor in self.seat_order() {
if self.selections.get(&actor).map(|s| s.action) != Some(Action::Ground) {
@ -638,8 +846,41 @@ impl GroundState {
work.fold(events.last().expect("just pushed"));
}
}
// GR-A11/A12 need a sub-choice command; pending.
Some(GroundMode::Ou) | Some(GroundMode::Nd) | None => {}
// 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 => {}
}
}
@ -669,9 +910,8 @@ impl GroundState {
work.fold(events.last().expect("just pushed"));
}
}
// GR-A05: Support through a Rivalry. The target's
// flip-or-break choice needs a consent command and is
// deferred; only the Stress effect applies here.
// GR-A05: Support through a Rivalry — 1 Stress, then
// the target flips it to a Bond or breaks it.
Some(Relation::Rivalry) => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
@ -679,9 +919,25 @@ impl GroundState {
stress,
});
work.fold(events.last().expect("just pushed"));
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.
_ => {}
}
}
// GR-A03: no relation. GR-L02 requires the target's
// consent to form a Bond, so no relation forms here.
// GR-A03/L02: no relation — 1 Stress, and a Bond forms
// only if the target accepts and both have a free slot.
None => {
let stress = work.stress_after(target, -1);
events.push(GroundEvent::StressSet {
@ -689,6 +945,15 @@ impl GroundState {
stress,
});
work.fold(events.last().expect("just pushed"));
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"));
}
}
}
}
@ -705,7 +970,13 @@ impl GroundState {
continue;
};
// GR-A09: Protection absorbs the Attack entirely.
// 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,
@ -880,6 +1151,73 @@ impl GroundState {
events
}
/// 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(()),
}
}
/// GR-A13 targeting legality, shared by every Action.
fn check_targeting(
&self,
@ -1027,6 +1365,8 @@ impl ScenarioGame for GroundState {
step: RoundStep::Select,
selections: BTreeMap::new(),
ground_modes: BTreeMap::new(),
ground_choices: BTreeMap::new(),
support_responses: BTreeMap::new(),
seed,
})
}
@ -1060,6 +1400,16 @@ impl ScenarioGame for GroundState {
"spend_freedom" => GroundCommand::SpendFreedom,
"choose_ground_mode" => GroundCommand::ChooseGroundMode {
mode: GroundMode::parse(&arg_str("mode")?)?,
choice: match step.args.get("choice") {
Some(_) => Some(GroundChoice::parse(
&arg_str("choice")?,
arg_u64("problem").or_else(|| arg_u64("seat")),
)?),
None => None,
},
},
"respond_to_support" => GroundCommand::RespondToSupport {
response: SupportResponse::parse(&arg_str("response")?)?,
},
"reveal" => GroundCommand::Reveal,
"resolve" => GroundCommand::Resolve,
@ -1103,6 +1453,8 @@ mod tests {
step: RoundStep::Select,
selections: BTreeMap::new(),
ground_modes: BTreeMap::new(),
ground_choices: BTreeMap::new(),
support_responses: BTreeMap::new(),
seed: 0,
}
}

View file

@ -0,0 +1,57 @@
scenario: ground/gr-a05-support-response
description: >
A Support target answers after Reveal: through a Rivalry they flip it
to a Bond or break it (GR-A05); with no relation a Bond forms only if
they accept (GR-L02). A player not receiving Support cannot answer.
covers: [GR-A05, GR-L02, GR-A03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"relations.0-1": Rivalry
commands:
- actor: P1
cmd: select_action
args: { action: SUPPORT, target: P2 }
- actor: P2
cmd: select_action
args: { action: SUPPORT, target: P3 }
- actor: P3
cmd: select_action
args: { action: INVESTIGATE, problem: 3 }
- actor: SYSTEM
cmd: reveal
# GR-A05: P2 is supported through a Rivalry, so it may flip.
- actor: P2
cmd: respond_to_support
args: { response: flip_to_bond }
# GR-L02: P3 has no relation with its supporter, so it accepts.
- actor: P3
cmd: respond_to_support
args: { response: accept_bond }
# P1 received no Support this round.
- actor: P1
cmd: respond_to_support
args: { response: accept_bond }
# GR-A05: a flip is not available where no relation exists.
- actor: P3
cmd: respond_to_support
args: { response: flip_to_bond }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: SupportAnswered
player: 1
response: FlipToBond
- kind: RelationFormed
relation: Bond
state:
"relations.0-1": Bond
"relations.1-2": Bond
"players.1.stress": 1
"players.2.stress": 1
rejects: [6, 7]

View file

@ -0,0 +1,43 @@
scenario: ground/gr-a11-ground-ou
description: >
GROUND—OU restores a Denied Problem (GR-A11), and a Denied Problem is
not otherwise revealable (GR-P02). A mode's sub-choice must belong to
that mode (GR-A10).
covers: [GR-A11, GR-P02, GR-P01]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"problems.2.denied": true
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-A10: GROUND—GR takes no sub-choice.
- actor: P1
cmd: choose_ground_mode
args: { mode: GR, choice: restore_problem, problem: 2 }
- actor: P1
cmd: choose_ground_mode
args: { mode: OU, choice: restore_problem, problem: 2 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: ProblemRestored
problem: 2
state:
"problems.2.denied": false
"problems.2.face_up": true
rejects: [4]

View file

@ -0,0 +1,47 @@
scenario: ground/gr-a12-ground-nd
description: >
GROUND—ND removes a Blame token from its player (GR-A12, GR-T02). The
choice must name a token that is actually there, and a relation break
must name a relation that exists.
covers: [GR-A12, GR-T02, GR-T03]
provisional: false
seed: 42
setup:
players: 3
preset: standard-3p
patch:
"lead": 0
"players.0.blame_from": [1]
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-T02: P3 has no Blame token in front of P1.
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: remove_blame, seat: 2 }
# GR-A12: no relation with P2 exists to break.
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: break_relation, seat: 1 }
- actor: P1
cmd: choose_ground_mode
args: { mode: ND, choice: remove_blame, seat: 1 }
- actor: SYSTEM
cmd: resolve
expect:
events:
- kind: BlameRemoved
player: 0
owner: 1
state:
"players.0.blame_from": []
rejects: [4, 5]