From 650970b32c72077b47af0f63e4f3d563bda0cff0 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:23:38 +0200 Subject: [PATCH 1/4] fix(engine): gate a reflexive "when you do" on the optional action actually happening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real game with Atraxa's Skitterfang: once the last oil counter was gone, the begin-combat trigger kept demanding a target and kept granting the chosen keyword. Reproduced end to end — with zero oil counters the engine never even offers the "you may" (CR 608.2d suppression already works), yet the reflexive fires and grants the keyword from nothing. Root cause: `evaluate_condition`'s `WhenYouDo` arm decided whether the parent event occurred by matching the parent's EFFECT TYPE against a hand-written list of three variants (`PayCost | Discard | DiscardCard`) and consulting `cost_payment_failed_flag`. `RemoveCounter` is not on that list, so the reflexive fired unconditionally. The engine already owns the answer. The sibling connector "if you do" (`EffectOutcome { OptionalEffectPerformed }`) reads `ability.context.optional_effect_performed` — the single record of "the player took the optional action". "When you do" asks the same question about the same parent, so it now reads the same authority instead of a parallel proxy list. The cost-payment gate is kept and is not subsumed: there the optional action WAS taken and the payment underneath failed. Scoped to `ability.optional`. A mandatory parent carries no performed-record, so gating on the bare flag would silence every mandatory reflexive (`RollDie`, `BecomeCopy`); the existing #418 negative control pins that. Class measured over all 35,795 cards: 261 reflexive riders, of which 173 have an optional parent and now share the authority (previously only the 86 PayCost/Discard ones were gated). Seven are the directly reported shape "you may remove a counter. When you do, …": Atraxa's Skitterfang, Biting-Palm Ninja, Forgehammer Centurion, Kappa Tech-Wrecker, Leatherhead Swamp Stalker, Overseer of Vault 76, Slumbering Walker. NOT covered, stated honestly: mandatory parents that silently do nothing (Vhal, "remove all study counters ... deals that much damage" with no counters) still fire their reflexive. Closing that needs a per-effect did-anything-happen record that does not exist yet; the observable damage there is 0. CR 603.12: a reflexive triggers based on whether the trigger event occurred earlier during the resolution. CR 608.2d: a player can't choose an impossible option. CR 122.1: removing a counter that isn't there does nothing. Tests: integration `skitterfang_reflexive_without_counter` (negative + positive reach guard) and unit `when_you_do_reads_the_optional_performed_record_not_the_effect_type` (all three rows on the same effect type, so the effect cannot be what carries the answer). Counter-measured: with the new gate disabled the negative row fails on the target demand and the positive row stays green. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/mod.rs | 83 +++++- crates/engine/tests/integration/main.rs | 1 + .../skitterfang_reflexive_without_counter.rs | 247 ++++++++++++++++++ 3 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 619dd31adb..ada38a1a35 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -12442,19 +12442,36 @@ pub(crate) fn evaluate_condition( .is_some_and(|f| f.flipper == ability.controller && f.result == *result), // CR 603.12: A reflexive triggered ability ("when you do") triggers // "based on whether the trigger event or events occurred earlier during - // the resolution" of the parent. For a cost-payment parent - // (`Effect::PayCost`), an unpayable or declined cost is NOT a trigger - // event occurrence, so the reflexive sub-ability must NOT fire — the - // `PayCost` and mandatory-discard handlers signal this via - // `cost_payment_failed_flag` (mirrors `IfYouDo` above). An accepted - // "you may discard a card" with an empty hand did not discard a card, - // so it cannot create the reflexive trigger. Other non-cost parents - // (e.g. `BecomeCopy` reflexives) remain unconditional. + // the resolution" of the parent. Two independent ways the parent event + // can fail to occur, each read through the authority that owns it: + // + // 1. An OPTIONAL parent whose action was never performed — declined, or + // never offered because it was impossible (CR 608.2d; + // `optional_effect_is_infeasible`, e.g. "you may remove an oil + // counter" with no oil counters). `optional_effect_performed` is the + // engine's single record of "the player took the optional action", + // and it is exactly what the sibling connector `IfYouDo` + // (`EffectOutcome { OptionalEffectPerformed }`, above) reads. The two + // connectors ask the same question about the same parent, so they + // must consult the same authority — "when you do" is not a weaker + // "if you do". A MANDATORY parent carries no such record (the flag + // stays false because no choice was ever offered), so the gate is + // scoped to `ability.optional` and mandatory reflexives + // (`BecomeCopy`, `RollDie`) remain unconditional. + // 2. An accepted parent whose payment then failed: unpayable cost, or an + // accepted "you may discard a card" with an empty hand. The `PayCost` + // and mandatory-discard handlers signal this via + // `cost_payment_failed_flag`. This is NOT subsumed by (1) — the + // optional action WAS taken, it is the payment underneath that did + // not happen — so both gates are load-bearing. AbilityCondition::WhenYouDo => { - !(matches!( + let optional_action_not_taken = + ability.optional && !ability.context.optional_effect_performed; + let payment_failed = matches!( ability.effect, Effect::PayCost { .. } | Effect::Discard { .. } | Effect::DiscardCard { .. } - ) && state.cost_payment_failed_flag) + ) && state.cost_payment_failed_flag; + !optional_action_not_taken && !payment_failed } // CR 601.2a + CR 707.10: "was cast (from [zone])" — check cast origin. // `zone: None` = cast from any origin; a copy or put-into-play object has @@ -18222,6 +18239,52 @@ mod tests { ); } + /// CR 603.12 + CR 608.2d: the reflexive connector "when you do" asks the + /// same question about the same parent as its sibling "if you do", so it + /// must read the same authority — `optional_effect_performed`. An optional + /// parent whose action was declined or never offered (because it was + /// impossible: "you may remove an oil counter" with no oil counters, + /// Atraxa's Skitterfang) did not produce the trigger event. + /// + /// The mandatory axis is the discriminator that keeps this from + /// over-suppressing: a parent with no "may" carries no performed-record at + /// all, so gating on the bare flag would silence every mandatory reflexive + /// (`RollDie`, `BecomeCopy`). All three rows below run against the same + /// `RemoveCounter` effect so the only thing varying is optionality and the + /// record — the effect type cannot be what carries the answer. + #[test] + fn when_you_do_reads_the_optional_performed_record_not_the_effect_type() { + let state = GameState::new_two_player(42); + let remove_counter = || Effect::RemoveCounter { + counter_type: Some(CounterType::Generic("oil".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 + }; + + assert!( + !evaluate_condition(&AbilityCondition::WhenYouDo, &state, &parent(true, false)), + "an optional parent whose action was never performed produced no \ + trigger event, so the reflexive must not fire (CR 603.12)" + ); + assert!( + evaluate_condition(&AbilityCondition::WhenYouDo, &state, &parent(true, true)), + "an optional parent whose action WAS performed must still fire its \ + reflexive — the gate must not suppress the working card" + ); + assert!( + evaluate_condition(&AbilityCondition::WhenYouDo, &state, &parent(false, false)), + "a MANDATORY parent carries no performed-record; gating on the bare \ + flag would silence every mandatory reflexive" + ); + } + #[test] fn chain_depth_exceeds_limit_returns_error() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 773467c335..32082155a5 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -962,6 +962,7 @@ mod serpent_society_ward_poison_cost; mod serras_emissary_chosen_card_type_protection; mod shorten_efficacy; mod sin_spiras_punishment_repeat; +mod skitterfang_reflexive_without_counter; mod skullwinder_chosen_opponent; mod slaughter_the_strong_total_power_4380; mod slitherwisp_flash_spell_cast_trigger; diff --git a/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs b/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs new file mode 100644 index 0000000000..f41a3709af --- /dev/null +++ b/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs @@ -0,0 +1,247 @@ +//! Atraxa's Skitterfang — "At the beginning of combat on your turn, you may +//! remove an oil counter from this creature. When you do, target creature you +//! control gains your choice of flying, vigilance, deathtouch, or lifelink +//! until end of turn." +//! +//! Reported from a real game: once the last oil counter was gone the trigger +//! kept asking for a target and kept granting the keyword. The removal is +//! impossible, so the reflexive event never occurs and nothing may be granted. +//! +//! Oracle text below is verified against `client/public/card-data.json`; the +//! first line ("enters with three oil counters") is omitted because the +//! scenario places the permanent directly and sets the counters itself. +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 603.12: a reflexive triggered ability triggers "based on whether the +//! trigger event or events occurred earlier during the resolution". +//! - CR 608.2d: a player can't choose an impossible option, so the "you may" +//! is never offered and the action is never taken. +//! - CR 122.1: removing a counter that isn't there does nothing. + +use engine::game::keywords::has_keyword; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::phase::Phase; +use engine::types::TargetRef; + +const SKITTERFANG: &str = "At the beginning of combat on your turn, you may remove an oil counter from this creature. When you do, target creature you control gains your choice of flying, vigilance, deathtouch, or lifelink until end of turn."; + +/// The four keywords the "your choice of" branch can grant. Asserting over all +/// of them (rather than the one the probe happened to pick) keeps the test from +/// passing merely because a different branch index was chosen. +const GRANTABLE: [Keyword; 4] = [ + Keyword::Flying, + Keyword::Vigilance, + Keyword::Deathtouch, + Keyword::Lifelink, +]; + +/// Branch index 1 = vigilance, in the printed order flying / vigilance / +/// deathtouch / lifelink. +const VIGILANCE_BRANCH: usize = 1; + +fn oil() -> CounterType { + CounterType::Generic("oil".to_string()) +} + +fn has_kw(runner: &mut GameRunner, id: ObjectId, keyword: &Keyword) -> bool { + runner.state_mut().layers_dirty.mark_full(); + evaluate_layers(runner.state_mut()); + has_keyword(&runner.state().objects[&id], keyword) +} + +struct Board { + runner: GameRunner, + skitterfang: ObjectId, + bears: ObjectId, +} + +fn board_with_oil(oil_counters: u32) -> Board { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::Untap); + let skitterfang = scenario + .add_creature_from_oracle(P0, "Atraxa's Skitterfang", 2, 2, SKITTERFANG) + .id(); + let bears = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + if oil_counters > 0 { + scenario.with_counter(skitterfang, oil(), oil_counters); + } + // Library padding so advancing the turn cannot deck anyone. + for _ in 0..10 { + scenario.add_card_to_library_top(P0, "Plains"); + } + let runner = scenario.build(); + Board { + runner, + skitterfang, + bears, + } +} + +/// Play the begin-combat trigger to completion, targeting Grizzly Bears and +/// picking vigilance. `take_the_may` decides the answer to the "you may remove +/// an oil counter" prompt. Records whether the reflexive ever demanded a target, +/// which is the observable half of "the trigger fired". Returns that flag. +fn play_the_trigger(board: &mut Board, take_the_may: bool) -> bool { + let mut reflexive_asked_for_a_target = false; + board.runner.advance_to_combat(); + for _ in 0..30 { + match board.runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { .. } => { + reflexive_asked_for_a_target = true; + board + .runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(board.bears)), + }) + .expect("choosing the reflexive's target must be allowed"); + } + WaitingFor::OptionalEffectChoice { .. } => { + board + .runner + .act(GameAction::DecideOptionalEffect { + accept: take_the_may, + }) + .expect("answering the counter-removal prompt must be allowed"); + } + WaitingFor::ChooseOneOfBranch { .. } => { + board + .runner + .act(GameAction::ChooseBranch { + index: VIGILANCE_BRANCH, + }) + .expect("choosing vigilance must be allowed"); + } + WaitingFor::OrderTriggers { triggers, .. } => { + let order = (0..triggers.len()).collect(); + board + .runner + .act(GameAction::OrderTriggers { order }) + .expect("ordering triggers must be allowed"); + } + WaitingFor::Priority { .. } => { + if board.runner.state().stack.is_empty() { + break; + } + board + .runner + .act(GameAction::PassPriority) + .expect("passing priority must be allowed"); + } + _ => break, + } + } + reflexive_asked_for_a_target +} + +/// The reported bug. With no oil counter the removal is impossible (CR 608.2d), +/// so the reflexive trigger never happens (CR 603.12): no target is demanded and +/// no creature gains anything. Reverting the `ability.optional && +/// !optional_effect_performed` gate in `evaluate_condition`'s `WhenYouDo` arm +/// re-grants the keyword from nothing. +#[test] +fn no_oil_counter_means_no_reflexive_trigger_and_no_keyword() { + let mut board = board_with_oil(0); + assert_eq!( + board.runner.state().objects[&board.skitterfang] + .counters + .get(&oil()) + .copied() + .unwrap_or(0), + 0, + "precondition: Atraxa's Skitterfang carries no oil counter" + ); + + let asked_for_a_target = play_the_trigger(&mut board, true); + + assert!( + !asked_for_a_target, + "CR 603.12: the removal could not happen, so the reflexive trigger must \ + never be created — it must not ask for a target" + ); + for keyword in &GRANTABLE { + assert!( + !has_kw(&mut board.runner, board.bears, keyword), + "no oil counter was removed, so nothing may be granted — but the \ + creature gained {keyword:?}" + ); + } +} + +/// Positive reach guard: with an oil counter present the card must still work +/// end to end — the counter comes off and the chosen keyword lands. This is what +/// proves the gate does not over-suppress a legitimate reflexive. +#[test] +fn one_oil_counter_still_removes_it_and_grants_the_chosen_keyword() { + let mut board = board_with_oil(1); + + let asked_for_a_target = play_the_trigger(&mut board, true); + + assert!( + asked_for_a_target, + "with an oil counter to remove, the reflexive must fire and target" + ); + assert_eq!( + board.runner.state().objects[&board.skitterfang] + .counters + .get(&oil()) + .copied() + .unwrap_or(0), + 0, + "accepting must remove the oil counter (1 -> 0)" + ); + assert!( + has_kw(&mut board.runner, board.bears, &Keyword::Vigilance), + "the chosen keyword must be granted to the targeted creature" + ); + for keyword in [Keyword::Flying, Keyword::Deathtouch, Keyword::Lifelink] { + assert!( + !has_kw(&mut board.runner, board.bears, &keyword), + "only the chosen branch may be granted — {keyword:?} leaked" + ); + } +} + +/// The other way the parent event fails to occur: an oil counter IS present, so +/// the "you may" is offered, and the player declines it. Nothing was removed, so +/// the reflexive must not fire (CR 603.12). +/// +/// Stated plainly: this row does NOT discriminate the new gate — measured, it +/// passes with the gate reverted too, because an explicitly declined optional is +/// suppressed structurally (`resolve_optional_effect_decision` never runs the +/// dependent sub-chain, so the condition is never reached). It is kept as a pin: +/// the decline path and the never-offered path must stay in agreement, and this +/// is what fails if a future change makes decline reach the gate instead. +#[test] +fn declining_the_removal_fires_no_reflexive_and_keeps_the_counter() { + let mut board = board_with_oil(1); + + let asked_for_a_target = play_the_trigger(&mut board, false); + + assert!( + !asked_for_a_target, + "a declined removal produced no trigger event, so the reflexive must \ + not ask for a target" + ); + assert_eq!( + board.runner.state().objects[&board.skitterfang] + .counters + .get(&oil()) + .copied() + .unwrap_or(0), + 1, + "declining must leave the oil counter in place" + ); + for keyword in &GRANTABLE { + assert!( + !has_kw(&mut board.runner, board.bears, keyword), + "the removal was declined, so nothing may be granted — but the \ + creature gained {keyword:?}" + ); + } +} From d246bf5158487bdcde34766f87b47124687c611a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 09:08:42 -0700 Subject: [PATCH 2/4] test(PR-7414): remove unsupported counter CR annotation Co-authored-by: Codex --- .../tests/integration/skitterfang_reflexive_without_counter.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs b/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs index f41a3709af..8cf078f900 100644 --- a/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs +++ b/crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs @@ -16,7 +16,6 @@ //! trigger event or events occurred earlier during the resolution". //! - CR 608.2d: a player can't choose an impossible option, so the "you may" //! is never offered and the action is never taken. -//! - CR 122.1: removing a counter that isn't there does nothing. use engine::game::keywords::has_keyword; use engine::game::layers::evaluate_layers; From e8016734c6943223dd26252197955027a76372dd Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:00:08 +0200 Subject: [PATCH 3/4] test(engine): pin that a declined optional never reaches a resumed reflexive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7414 review (CodeRabbit): the deferred-continuation carrier does not copy the parent's `optional`, so the new `WhenYouDo` gate is inert at that call site. The observation is correct — instrumented the resumed call site and ran the whole integration suite: 26 arrivals, `optional == false` in every one. The combination that would be a bug is unreachable. You can only resume what suspended, and an optional gate only suspends AFTER being accepted; declining ends the chain before a continuation exists, and an infeasible optional is never offered. Measured by source card, every arrival is either a mandatory parent (Ancient Brass/Bronze Dragon, Foray of Orcs, Grishnakh, North Pole Research Base, Ratonhnhaketon — no "you may" in any of their Oracle text, so firing is correct) or an accepted optional (Inti, Swashbuckler Extraordinaire, Iroh, Synth, Atraxa's Skitterfang). There is no third row. Not taking the suggested remedy: `ability.optional` is also the entry condition of `upfront_optional_gate`, so copying it onto the continuation carrier would prompt the player a SECOND time after resumption for a decision already made. If this ever needs closing, the signal belongs on the context, which already travels to both call sites and drives no prompts. Adds the regression the review asked for, on the card the resolver's own comment names for this path. Declining Inti's discard leaves both cards in hand, suspends nothing, demands no target, and puts no counter or trample. Stated in the test: this row does NOT discriminate the gate — it passes with the gate reverted too, because a declined optional never reaches the condition. It pins the reachability argument instead, which is what makes the carrier's missing `optional` harmless. Co-Authored-By: Claude Opus 5 --- .../tests/integration/issue_1328_inti.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/engine/tests/integration/issue_1328_inti.rs b/crates/engine/tests/integration/issue_1328_inti.rs index b01597cdaf..0de908d750 100644 --- a/crates/engine/tests/integration/issue_1328_inti.rs +++ b/crates/engine/tests/integration/issue_1328_inti.rs @@ -62,6 +62,19 @@ fn inti_attack_trigger_ast_has_reflexive_counter_after_optional_discard() { ); } +fn hand_count( + runner: &engine::game::scenario::GameRunner, + player: engine::types::PlayerId, +) -> usize { + runner + .state() + .players + .iter() + .find(|p| p.id == player) + .map(|p| p.hand.len()) + .expect("player exists") +} + fn p1p1(runner: &engine::game::scenario::GameRunner, id: ObjectId) -> u32 { runner .state() @@ -202,3 +215,83 @@ fn inti_reflexive_counter_after_interactive_discard_choice() { "Inti must also grant trample to the chosen attacker" ); } + +/// PR #7414 review (CodeRabbit): the deferred-continuation carrier loses the +/// parent's `optional`, so the question was whether a DECLINED optional parent +/// can run its reflexive after a suspension. +/// +/// It cannot, and this row pins why: declining ends the chain before anything +/// suspends. `DiscardChoice` — the suspension in this card — is only reached +/// once the "you may" has been ACCEPTED, so there is no continuation to resume +/// and the reflexive is never created. Two cards stay in hand, no counter, no +/// trample. +/// +/// The accept-side twin is `inti_reflexive_counter_after_interactive_discard_choice` +/// above; together they cover both answers to the same prompt on the one card +/// whose suspended path the resolver's own comment names. +/// +/// Stated plainly: this row does NOT discriminate the PR #7414 gate — measured, +/// it passes with that gate reverted too, because an explicitly declined +/// optional never reaches the condition at all. It pins the reachability +/// argument (decline ⇒ no suspension ⇒ no resumed carrier), which is what makes +/// the carrier's missing `optional` harmless. +#[test] +fn inti_declined_discard_suspends_nothing_and_fires_no_reflexive() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Inti, Seneschal of the Sun", 2, 2, INTI_ATTACK_ABILITY); + let attacker = scenario.add_creature(P0, "Attacker", 2, 2).id(); + scenario.add_card_to_hand(P0, "Discard A"); + scenario.add_card_to_hand(P0, "Discard B"); + + let mut runner = scenario.build(); + runner.pass_both_players(); + runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Player(P1))], + bands: vec![], + }) + .expect("declare attackers"); + runner.pass_both_players(); + + let hand_before = hand_count(&runner, P0); + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("declining the optional discard must be allowed"); + + assert!( + !matches!(runner.state().waiting_for, WaitingFor::DiscardChoice { .. }), + "declining must not suspend into a discard choice, got {:?}", + runner.state().waiting_for + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "CR 603.12: nothing was discarded, so the reflexive must not target, got {:?}", + runner.state().waiting_for + ); + + runner.advance_until_stack_empty(); + + assert_eq!( + hand_count(&runner, P0), + hand_before, + "declining must not discard a card" + ); + assert_eq!( + p1p1(&runner, attacker), + 0, + "a declined discard puts no +1/+1 counter on the attacker" + ); + assert!( + !runner + .state() + .objects + .get(&attacker) + .expect("attacker remains on the battlefield") + .has_keyword(&Keyword::Trample), + "a declined discard grants no trample" + ); +} From bf5c79b0cd4e5ffc03e45d79b145c6ddfdab28e8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 10:09:11 -0700 Subject: [PATCH 4/4] fix(PR-7414): clarify reflexive condition Co-authored-by: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> --- crates/engine/src/types/ability.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 28359de384..8d93d7d69f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -20707,12 +20707,13 @@ pub enum AbilityCondition { /// process") and any cross-sentence flip-result gate. CoinFlipOutcome { result: CoinFlipResult }, /// CR 603.12: "When you do" — reflexive trigger that fires based on whether the - /// parent's trigger event actually occurred. For a non-cost parent (e.g. a - /// `BecomeCopy` reflexive or a copy/exile replacement sub-ability) the "do" - /// always occurred, so this is unconditionally true. For a cost-payment parent - /// (`Effect::PayCost`), an unpayable or declined cost is not an occurrence, so - /// the reflexive sub-ability is skipped — `evaluate_condition` gates on - /// `cost_payment_failed_flag` for that case (mirrors `IfYouDo`). + /// parent's trigger event actually occurred. A mandatory non-cost parent (e.g. + /// a `BecomeCopy` reflexive or a copy/exile replacement sub-ability) always + /// occurred, while an optional non-cost parent must actually be performed. + /// For a cost-payment parent (`Effect::PayCost`), an unpayable or declined cost + /// is not an occurrence, so the reflexive sub-ability is skipped — + /// `evaluate_condition` gates on `cost_payment_failed_flag` for that case + /// (mirrors `IfYouDo`). WhenYouDo, /// CR 601.2a + CR 707.10: "if [this spell] was cast from [zone]" — sub_ability /// executes only if the spell was cast. `zone: None` = cast from any origin;