diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md index 17e5987..bf5f62a 100644 --- a/.claude/ralph-loop.local.md +++ b/.claude/ralph-loop.local.md @@ -1,6 +1,6 @@ --- active: true -iteration: 2 +iteration: 3 session_id: 8cbd5701-a096-45a4-a419-9b7b1c9419bc max_iterations: 20 completion_promise: "HEUREKA" diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 7a12d96..8245d93 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -134,6 +134,10 @@ pub struct GroundState { pub selections: BTreeMap, /// GR-R05: GROUND modes chosen after Reveal, before Resolve. pub ground_modes: BTreeMap, + /// GR-A11/A12: the sub-choice accompanying an OU or ND mode. + pub ground_choices: BTreeMap, + /// GR-L02/A05: a Support target's response, keyed by target. + pub support_responses: 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, @@ -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) -> Result { + 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 { + 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 { 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, + }, + /// 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, + }, + /// 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, + ) -> 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, } } diff --git a/scenarios/ground/gr-a05-support-response.yaml b/scenarios/ground/gr-a05-support-response.yaml new file mode 100644 index 0000000..1208435 --- /dev/null +++ b/scenarios/ground/gr-a05-support-response.yaml @@ -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] diff --git a/scenarios/ground/gr-a11-ground-ou.yaml b/scenarios/ground/gr-a11-ground-ou.yaml new file mode 100644 index 0000000..97c2167 --- /dev/null +++ b/scenarios/ground/gr-a11-ground-ou.yaml @@ -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] diff --git a/scenarios/ground/gr-a12-ground-nd.yaml b/scenarios/ground/gr-a12-ground-nd.yaml new file mode 100644 index 0000000..e363f01 --- /dev/null +++ b/scenarios/ground/gr-a12-ground-nd.yaml @@ -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]