Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6376,6 +6376,30 @@ fn affected_objects_from_events(
}
}

/// CR 603.12 (#7511): A reflexive "when you do" triggers "based on whether the
/// trigger event or events occurred earlier during the resolution" of its
/// parent. The `WhenYouDo` arm of `evaluate_condition` covers the OPTIONAL
/// parent (declined / infeasible — #7414) and the failed-payment class; it
/// cannot see the resolution's event slice, so the MANDATORY-parent question
/// ("the instruction ran and did nothing") is answered here, at the sub-walk
/// call site that has the parent's own events in hand. Mirrors the CR 608.2c
/// mandatory-rider seed's exclusions: an outcome-owning parent (coin flip,
/// clash, dig, behold — `effect_manages_own_outcome_flag`) keeps its own
/// record, an effect kind without an event witness stays "mandatory means
/// yes" (`mandatory_parent_effect_performed`'s default arm), and any recorded
/// performance (`optional_effect_performed`) always wins.
fn when_you_do_mandatory_parent_did_nothing(
condition: &AbilityCondition,
parent: &ResolvedAbility,
parent_events: &[GameEvent],
) -> bool {
matches!(condition, AbilityCondition::WhenYouDo)
&& !parent.optional
&& !parent.context.optional_effect_performed
&& !effect_manages_own_outcome_flag(&parent.effect)
&& !mandatory_parent_effect_performed(&parent.effect, parent_events)
}

fn mandatory_parent_effect_performed(effect: &Effect, events: &[GameEvent]) -> bool {
match effect {
Effect::Destroy { .. } | Effect::DestroyAll { .. } => events.iter().any(|event| {
Expand Down Expand Up @@ -12597,7 +12621,16 @@ fn resolve_chain_body(
ability
};

let condition_met = evaluate_condition(condition, state, condition_ability);
// CR 603.12 (#7511): a MANDATORY parent whose witnessed action did
// nothing — "when you do" never happened. Suppression routes
// through the ordinary false path below, so an else branch and the
// surviving sequential siblings keep their printed semantics.
let condition_met = evaluate_condition(condition, state, condition_ability)
&& !when_you_do_mandatory_parent_did_nothing(
condition,
ability,
&events[events_before..],
);
if !condition_met {
// CR 608.2c: Execute else branch if present ("Otherwise, [effect]")
if let Some(ref else_branch) = sub.else_ability {
Expand Down Expand Up @@ -20549,6 +20582,110 @@ mod tests {
);
}

/// CR 603.12 (#7511): the MANDATORY-parent stage of the "when you do"
/// gate — answered at the sub-walk call site from the parent's own event
/// slice, because the arm above has no events and (as its third row pins)
/// must keep saying "mandatory means yes". Rows vary one axis at a time
/// around the same `RemoveCounter` parent (Vhal, Scholar of Mortality's
/// shape: "remove all study counters from it. When you do, …" with zero
/// counters).
#[test]
fn a_mandatory_parent_that_did_nothing_suppresses_its_reflexive() {
let remove_counter = || Effect::RemoveCounter {
counter_type: Some(CounterType::Generic("study".to_string())),
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::SelfRef,
};
let parent = |optional: bool, performed: bool| {
let mut ability =
ResolvedAbility::new(remove_counter(), vec![], ObjectId(100), PlayerId(0));
ability.optional = optional;
ability.context.optional_effect_performed = performed;
ability
};
let when_you_do = AbilityCondition::WhenYouDo;
let no_events: Vec<GameEvent> = vec![];
let witnessed = vec![GameEvent::CounterRemoved {
object_id: ObjectId(100),
counter_type: CounterType::Generic("study".to_string()),
count: 2,
}];

// The suppression case: mandatory, no record, no witness event.
assert!(
when_you_do_mandatory_parent_did_nothing(
&when_you_do,
&parent(false, false),
&no_events
),
"a mandatory RemoveCounter that removed nothing did not happen (CR 603.12)"
);
// The witness event clears it — the working card keeps its reflexive.
assert!(
!when_you_do_mandatory_parent_did_nothing(
&when_you_do,
&parent(false, false),
&witnessed
),
"a CounterRemoved event is the parent's occurrence — no suppression"
);
// An optional parent is the arm's business, never this stage's.
assert!(
!when_you_do_mandatory_parent_did_nothing(
&when_you_do,
&parent(true, false),
&no_events
),
"the optional axis is owned by the WhenYouDo arm (#7414), not this stage"
);
// A recorded performance always wins.
assert!(
!when_you_do_mandatory_parent_did_nothing(
&when_you_do,
&parent(false, true),
&no_events
),
"a recorded performance must never be second-guessed"
);
// An outcome-owning parent (RollDie) keeps its own record.
let mut roll = ResolvedAbility::new(
Effect::RollDie {
count: QuantityExpr::Fixed { value: 1 },
sides: 6,
results: vec![],
modifier: None,
},
vec![],
ObjectId(100),
PlayerId(0),
);
roll.optional = false;
assert!(
!when_you_do_mandatory_parent_did_nothing(&when_you_do, &roll, &no_events),
"an outcome-owning parent (effect_manages_own_outcome_flag) is exempt"
);
// A kind without an event witness stays "mandatory means yes"
// (`mandatory_parent_effect_performed`'s default arm) — BecomeCopy is
// the arm test's own example of a mandatory reflexive that must stay
// unconditional.
let become_copy = ResolvedAbility::new(
Effect::BecomeCopy {
recipient: TargetFilter::SelfRef,
target: TargetFilter::SelfRef,
duration: None,
mana_value_limit: None,
additional_modifications: vec![],
},
vec![],
ObjectId(100),
PlayerId(0),
);
assert!(
!when_you_do_mandatory_parent_did_nothing(&when_you_do, &become_copy, &no_events),
"an effect kind without an event witness must stay unconditional"
);
}

#[test]
fn chain_depth_exceeds_limit_returns_error() {
let mut state = GameState::new_two_player(42);
Expand Down
6 changes: 3 additions & 3 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19352,9 +19352,9 @@ mod stage2_injector_tests {
// resume/finalization helpers are above this existing producer;
// they do not mint an optional-effect prompt. The census above
// still finds exactly the same five production producers.
"game/effects/mod.rs:7344".to_string(),
"game/effects/mod.rs:7421".to_string(),
"game/effects/mod.rs:11248".to_string(),
"game/effects/mod.rs:7368".to_string(),
"game/effects/mod.rs:7445".to_string(),
"game/effects/mod.rs:11272".to_string(),
// UNMOVED across the rebase, and that is itself evidence the SET did not
// move: a census that had gained or lost a producer would not leave this
// entry both byte-identical AND at the same coordinate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
//! - CR 700.2b: a modal triggered ability chooses its mode(s) as it is put on
//! the stack.
//!
//! What this file does NOT prove: it does not measure the CR 603.12 gate. With
//! every graveyard empty the instruction now runs and exiles nothing, but the
//! reflexive is still created and still asks for a mode. Suppressing it needs
//! the engine to record that a mandatory instruction did nothing — issue #7511's
//! remaining half, deliberately out of scope here.
//! The third test measures the CR 603.12 gate itself: with every graveyard
//! empty the mandatory instruction runs, moves nothing, and the reflexive is
//! never created — issue #7511's remaining half, answered from the parent's
//! own event slice at the sub-walk site in `resolve_ability_chain`
//! (`when_you_do_mandatory_parent_did_nothing`).

use engine::game::scenario::{GameScenario, P0, P1};
use engine::types::actions::GameAction;
Expand Down Expand Up @@ -179,23 +179,25 @@ fn the_mode_list_still_resolves_after_the_instruction() {
);
}

/// With nothing to exile, the instruction runs and moves no card.
/// With nothing to exile, the instruction runs, moves no card — and the
/// reflexive is never created.
///
/// This row does NOT assert that the resolution asks nothing. It still asks for
/// a mode: CR 603.12 says the reflexive should never have been created, but the
/// engine has no record that a mandatory instruction did nothing. That gap is
/// issue #7511's remaining half and is not addressed here.
/// CR 603.12: a reflexive triggered ability triggers "based on whether the
/// trigger event or events occurred earlier during the resolution". With every
/// graveyard empty the mandatory "exile another card from a graveyard" exiles
/// nothing, so "when you do" never happened: no mode choice may be offered.
/// This closes issue #7511's remaining half (the optional-parent side landed
/// in #7414).
#[test]
fn an_impossible_exile_moves_no_card() {
fn an_impossible_exile_creates_no_reflexive() {
let resolved = resolve_enters(false);
// Reach-guard: no object named "Fodder Card" exists in this game, so the
// census below would read (0, 0) even if the card failed to parse or the
// trigger never fired. The mode choice proves the enters trigger resolved
// its instruction and created the reflexive.
assert!(
resolved.prompts.iter().any(|p| p == "AbilityModeChoice"),
"the enters trigger must have run its instruction and created the \
reflexive mode choice — {:?}",
!resolved
.prompts
.iter()
.any(|p| p == "AbilityModeChoice" || p == "TargetSelection"),
"CR 603.12: the mandatory exile did nothing, so the reflexive mode \
choice must never be offered — prompts seen: {:?}",
resolved.prompts
);
assert_eq!(
Expand Down
Loading