-
-
Notifications
You must be signed in to change notification settings - Fork 155
fix(engine): gate a reflexive "when you do" on the optional action actually happening #7414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
matthewevans
merged 6 commits into
phase-rs:main
from
cuinhellcat:fix/reflexive-trigger-unperformed-parent
Aug 15, 2026
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
650970b
fix(engine): gate a reflexive "when you do" on the optional action ac…
cuinhellcat d246bf5
test(PR-7414): remove unsupported counter CR annotation
matthewevans e801673
test(engine): pin that a declined optional never reaches a resumed re…
cuinhellcat 5c83d9f
Merge remote-tracking branch 'origin/main' into fix/reflexive-trigger…
cuinhellcat bf5c79b
fix(PR-7414): clarify reflexive condition
matthewevans 5e19b92
Merge remote-tracking branch 'fork/fix/reflexive-trigger-unperformed-…
cuinhellcat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
247 changes: 247 additions & 0 deletions
247
crates/engine/tests/integration/skitterfang_reflexive_without_counter.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:?}" | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50370
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 34284
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 208
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 11324
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 39441
Propagate parent optionality on deferred
WhenYouDocontinuationsWhen a parent suspends, the deferred path clones
suband copies onlycontext;apply_parent_chain_contextdoes not copyoptional. On resume,WhenYouDois evaluated against that child withparent = None, soability.optionalremainsfalseand a declined optional parent can incorrectly run its reflexive. Preserve the parent’s optionality on this condition carrier, and add a regression test for the suspended path.🤖 Prompt for AI Agents