Skip to content

fix(engine): prevent-damage recipient scope no longer collapses to Any - #6793

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
claytonlin1110:fix/6682-mutational-advantage-prevent-recipient
Jul 29, 2026
Merged

fix(engine): prevent-damage recipient scope no longer collapses to Any#6793
matthewevans merged 3 commits into
phase-rs:mainfrom
claytonlin1110:fix/6682-mutational-advantage-prevent-recipient

Conversation

@claytonlin1110

@claytonlin1110 claytonlin1110 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #6682. Mutational Advantage's second clause ("Prevent all damage that would be dealt to those permanents this turn") was parsing its recipient to a blanket TargetFilter::Any, protecting every permanent in the game instead of only the caster's own countered permanents. The same fallback silently over-broadened Blinding Fog ("creatures"), Defend the Hearth ("players"), and Energy Arc ("dealt to and dealt by those creatures") via three different recipient shapes.

Root cause

parse_prevent_effect's recipient dispatch never routed the "dealt to <recipient>" phrase through the shared parse_target grammar — it only handled a closed set of literal phrases (any target, target creature/target permanent, to you, a couple of compounds) plus a single-object anaphor gated on a prior chosen target. Anything else — mass typed nouns ("creatures", "players") and the "those permanents"/"those creatures" population anaphor — fell through to Any.

Fix

  • Parser: a new shared resolve_prevent_recipient tier ladder (used by both parse_prevent_effect and the bidirectional "dealt to and dealt by" path) tries, in order: (1) a singular chosen-target anaphor, (2) "those permanents"/"those creatures" bound to the population an earlier same-chain Continuous-mode static grant locked onto (a widened chain_prior_mass_population, verified against Mutational Advantage's official ruling that the affected set is fixed at resolution), (3) any other recipient parse_target recognizes. Also adds a players bare-noun grammar arm (Player has no TypeFilter representation, so it was never reachable before).
  • Runtime: prevent_damage::resolve now freezes a TrackedSet sentinel to a concrete id at shield-creation time, so a persisting shield can't drift onto whatever an unrelated later chain happens to publish. find_applicable_replacements now enforces valid_card against object damage targets on the global/stack-sourced shield path — previously ignored entirely for that path (proven by a pre-existing test explicitly asserting the old behavior, now split to cover the player-target case it actually applies to), which meant even a correctly-parsed typed/tracked-set recipient from an instant/sorcery source had no runtime effect.

CR references

  • CR 615.1 / 615.1a — prevention effects as shields
  • CR 611.2c — a continuous effect's affected set locks in when it begins
  • CR 615.11 — untargeted multi-object prevention shields freeze their population at resolution
  • CR 608.2c — cross-clause anaphor resolution follows instruction order

Test plan

  • cargo test -p phase-engine --lib — 17,983 passed, 0 failed
  • cargo clippy -p phase-engine --lib -- -D warnings — clean
  • cargo fmt --all
  • New parser tests: Mutational Advantage, Blinding Fog, Defend the Hearth, Energy Arc (bidirectional), plus a regression guard that a genuinely recipient-free Fog effect still resolves to Any
  • New runtime tests: TrackedSet sentinel resolves to a concrete id immune to a later unrelated chain overwriting it; valid_card is now enforced against object damage targets on the global shield path

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved prevention effects that reference previously selected creatures or permanents.
    • Ensured damage-prevention filters correctly apply to specific object targets while preserving player-target behavior.
    • Fixed shields so tracked recipients remain tied to the original selection, even after later effects create new selections.
    • Corrected population tracking for continuous effects and related damage-source filters.
  • Parser Improvements

    • Improved recognition of prevention recipients, including bare “players,” typed recipients, and tracked groups.
    • Added support for resolving recipient and source references consistently in bidirectional prevention effects.

phase-rs#6682)

Mutational Advantage's "Prevent all damage that would be dealt to those
permanents this turn" parsed its recipient to a blanket TargetFilter::Any,
protecting every permanent in the game instead of only the caster's own
countered ones (Blinding Fog, Defend the Hearth, and Energy Arc hit the same
fallback via different recipient shapes). The dispatch chain in
parse_prevent_effect never called into parse_target's tracked-set/typed-noun
grammar for the recipient position, and a bare "players" noun had no grammar
arm at all.

Adds a shared resolve_prevent_recipient tier ladder (chosen-target anaphor ->
clause-derived population via a widened chain_prior_mass_population -> any
other parse_target-recognized filter), a "players" bare-noun arm in
parse_target, and the corresponding runtime plumbing: prevent_damage::resolve
now freezes a TrackedSet sentinel to a concrete id at shield-creation time
(avoiding drift onto whatever a later, unrelated chain publishes), and
find_applicable_replacements now enforces valid_card against object damage
targets on the global/stack-sourced shield path, which it previously ignored
entirely for that recipient class.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e2ca0cd-f193-4be4-842e-9f2b733fc1e1

📥 Commits

Reviewing files that changed from the base of the PR and between 408b068 and 972338c.

📒 Files selected for processing (5)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/prevent_damage.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/game/effects/prevent_damage.rs

📝 Walkthrough

Walkthrough

Prevent-damage parsing now resolves typed, player, chosen-target, and tracked-set recipients. Population binding and shield resolution preserve concrete tracked-set identities, while global object-target prevention enforces valid_card filters.

Changes

Prevent damage scope handling

Layer / File(s) Summary
Capture prior population context
crates/engine/src/game/effects/mod.rs, crates/engine/src/parser/oracle_effect/mod.rs
Continuous static grants are included when selecting populations for same-chain anaphors, and generic-effect resolution freezes the affected population.
Resolve prevent recipients
crates/engine/src/parser/oracle_effect/imperative.rs, crates/engine/src/parser/oracle_effect/lower.rs, crates/engine/src/parser/oracle_target.rs
Prevent recipient parsing now supports typed and bare-player recipients, tracked-set resolution, and shared bidirectional recipient handling.
Validate parser scoping
crates/engine/src/parser/oracle_effect/tests.rs, crates/engine/src/parser/oracle_effect/imperative.rs, crates/engine/src/parser/oracle_target.rs
Regression tests cover prior populations, typed creatures, players, fallback behavior, word boundaries, and bidirectional tracked-set bindings.
Resolve and apply shield scopes
crates/engine/src/game/effects/prevent_damage.rs, crates/engine/src/game/replacement.rs
Tracked-set sentinels are resolved when shields are created, source-scoped prevention recognizes tracked-set filters, and object-target global prevention applies valid_card.
Validate runtime matching
crates/engine/src/game/effects/prevent_damage.rs, crates/engine/src/game/replacement.rs
Runtime tests verify tracked-set identity remains stable and valid_card differs correctly for player and object damage targets.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OracleParser
  participant ChainPopulation
  participant PreventShield
  participant ReplacementPlanner
  OracleParser->>ChainPopulation: resolve prior population
  ChainPopulation-->>OracleParser: return tracked-set filter
  OracleParser->>PreventShield: create scoped prevention shield
  PreventShield->>PreventShield: freeze tracked-set id
  ReplacementPlanner->>PreventShield: evaluate damage event
  PreventShield-->>ReplacementPlanner: apply recipient and valid-card filters
Loading

Possibly related issues

  • phase-rs/phase#6682 — Covers parser and tracked-set resolution defects where prevent recipients collapse to Any.

Suggested labels: bug

Suggested reviewers: matthewevans, invalidcards

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: prevent-damage recipient handling no longer falls back to Any.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@matthewevans matthewevans self-assigned this Jul 29, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested

Reviewed current head 408b068f0f48f6c7364e8dccac43f8e39af7f1b6.

  1. [HIGH] Snapshot the clause-derived Mutational Advantage population at resolution. crates/engine/src/parser/oracle_effect/mod.rs:30012 carries the population as a raw TargetFilter; crates/engine/src/game/effects/prevent_damage.rs:289 binds only the pre-existing TrackedSet; and crates/engine/src/game/engine_replacement.rs:7218 live-re-evaluates the filter. The official ruling says the affected set is determined on resolution. Snapshot the clause-derived population while bare typed recipients stay live, and add gained-counter and lost-counter runtime tests.

  2. [MED] Exercise the Energy Arc cast pipeline. The parser-only tests at crates/engine/src/parser/oracle_effect/tests.rs:49555 and :49644, plus runtime fixtures that hand-construct at crates/engine/src/game/effects/prevent_damage.rs:1528 and directly push at crates/engine/src/game/engine_replacement.rs:19523, bypass the Energy Arc cast pipeline. Add selected/nonselected cast-pipeline damage-to and damage-by coverage.

  3. [MED] Provide the required current-head parse-diff artifact. This PR changes parser code, including crates/engine/src/parser/oracle_effect/imperative.rs:5958 and crates/engine/src/parser/oracle_target.rs:1638, but the required parse-diff sticky comment is missing. Add the full artifact before re-review.

@matthewevans matthewevans removed their assignment Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/engine/src/game/replacement.rs (1)

7206-7222: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the sentinel FilterContext instead of rebuilding it.

This is the same 6-line construction as the damage_source_filter block at Lines 7148-7153. Build it once next to source_controller (Line 7137) and share it; both gates want identical semantics, so a future divergence would be silent.

♻️ Suggested consolidation
                     let source_controller =
                         repl_def.source_controller.unwrap_or(state.active_player);
+                    // CR 109.4 + CR 614.1a: the pending host `ObjectId(0)` has
+                    // no entry in `state.objects`, so a controller-relative
+                    // filter needs the install-time anchor when present.
+                    let sentinel_ctx = match repl_def.source_controller {
+                        Some(pid) => FilterContext::from_source_with_controller(ObjectId(0), pid),
+                        None => FilterContext::from_source(state, ObjectId(0)),
+                    };

then use &sentinel_ctx in both the damage_source_filter and valid_card gates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/replacement.rs` around lines 7206 - 7222, Hoist the
sentinel FilterContext construction beside source_controller in the surrounding
replacement logic, creating it once with the same controller-dependent
semantics. Reuse the shared sentinel context in both the damage_source_filter
gate and the valid_card gate, including the matches_target_filter call, instead
of rebuilding it locally.
crates/engine/src/parser/oracle_effect/imperative.rs (1)

5918-5927: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider a terminal-boundary guard on parse_those_population_anaphor.

The combinator is a bare prefix tag with no check that the phrase ends at a word/clause boundary, so a longer phrase sharing the same prefix (e.g. "those permanents'" or an unanticipated suffix) would still satisfy .is_ok() and route into the clause-derived-population tier. Not exploitable by any card in the targeted class today, but the repo's own parser convention for recipient/anaphor predicates is to enforce a terminal boundary (see parse_recipient_is_filter_condition in oracle_nom/condition.rs).

♻️ Illustrative fix
-fn parse_those_population_anaphor(input: &str) -> OracleResult<'_, ()> {
-    value((), alt((tag("those permanents"), tag("those creatures")))).parse(input)
-}
+fn parse_those_population_anaphor(input: &str) -> OracleResult<'_, ()> {
+    value(
+        (),
+        terminated(
+            alt((tag("those permanents"), tag("those creatures"))),
+            peek(alt((eof, tag(" "), tag(".")))),
+        ),
+    )
+    .parse(input)
+}

As per path instructions, "Use word-boundary scanning with nom combinators for timing or keyword phrases that may occur at arbitrary positions; do not use scattered contains() checks" and the referenced condition.rs guidance to "enforce terminal boundaries so longer phrases don't get accidentally consumed."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/imperative.rs` around lines 5918 -
5927, Update parse_those_population_anaphor to require a terminal word or clause
boundary after either “those permanents” or “those creatures,” following the
boundary-enforcement pattern used by parse_recipient_is_filter_condition.
Preserve the existing anchored prefix matching and successful recognition of the
exact phrases while rejecting longer suffixes such as possessives or other
attached text.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/parser/oracle_effect/tests.rs`:
- Around line 49639-49685: Update
energy_arc_bidirectional_prevent_binds_to_untapped_targets so it extracts the
TrackedSet id from both the recipient target and damage_source_filter, then
asserts the two ids are equal. Preserve the existing checks that both shields
use TrackedSet and that the recipient is not TargetFilter::Any.

---

Nitpick comments:
In `@crates/engine/src/game/replacement.rs`:
- Around line 7206-7222: Hoist the sentinel FilterContext construction beside
source_controller in the surrounding replacement logic, creating it once with
the same controller-dependent semantics. Reuse the shared sentinel context in
both the damage_source_filter gate and the valid_card gate, including the
matches_target_filter call, instead of rebuilding it locally.

In `@crates/engine/src/parser/oracle_effect/imperative.rs`:
- Around line 5918-5927: Update parse_those_population_anaphor to require a
terminal word or clause boundary after either “those permanents” or “those
creatures,” following the boundary-enforcement pattern used by
parse_recipient_is_filter_condition. Preserve the existing anchored prefix
matching and successful recognition of the exact phrases while rejecting longer
suffixes such as possessives or other attached text.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 556cee02-a4b1-4023-b8d3-81fbba407144

📥 Commits

Reviewing files that changed from the base of the PR and between 3658a21 and 408b068.

📒 Files selected for processing (8)
  • crates/engine/src/game/effects/prevent_damage.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_target.rs

Comment on lines +49639 to +49685
/// CR 608.2c + CR 615 (issue #6682): Energy Arc's bidirectional "dealt to and
/// dealt by those creatures" must bind BOTH shields to the untapped creatures
/// selected by the preceding clause — a target-derived tracked-set anaphor —
/// not the `Any` fallback that silently protected/exposed every creature.
#[test]
fn energy_arc_bidirectional_prevent_binds_to_untapped_targets() {
let def = parse_effect_chain(
"Untap any number of target creatures. Prevent all combat damage that would be \
dealt to and dealt by those creatures this turn.",
AbilityKind::Spell,
);
let prevent = def
.sub_ability
.as_deref()
.expect("the prevent clause must chain after the untap");
let Effect::PreventDamage { target, .. } = &*prevent.effect else {
panic!(
"expected the recipient (\"to\") shield, got {:?}",
prevent.effect
);
};
assert!(
matches!(target, TargetFilter::TrackedSet { .. }),
"the recipient shield must bind to the untapped-creatures tracked set, got {target:?}"
);
assert_ne!(*target, TargetFilter::Any);

let by_ability = prevent
.sub_ability
.as_deref()
.expect("the source (\"by\") shield must chain as a sequential sibling");
let Effect::PreventDamage {
damage_source_filter,
..
} = &*by_ability.effect
else {
panic!(
"expected the source (\"by\") shield, got {:?}",
by_ability.effect
);
};
assert!(
matches!(damage_source_filter, Some(TargetFilter::TrackedSet { .. })),
"the source shield must also bind to the same tracked set, got {damage_source_filter:?}"
);
}

Copy link
Copy Markdown
Contributor

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

Energy Arc test doesn't verify both shields bind the same tracked set.

The test only checks that the recipient's target and the source's damage_source_filter are each some TargetFilter::TrackedSet, but never compares their ids. The PR's own claim — "binds both the recipient and the damage-source filter to the same tracked-set" — is therefore not actually verified: a regression that produced two independently-created tracked sets (e.g. TrackedSetId(0) vs TrackedSetId(1)) would still pass this test.

🐛 Proposed fix to assert the same tracked-set id on both shields
-    assert!(
-        matches!(target, TargetFilter::TrackedSet { .. }),
-        "the recipient shield must bind to the untapped-creatures tracked set, got {target:?}"
-    );
+    let TargetFilter::TrackedSet { id: recipient_id } = target else {
+        panic!("the recipient shield must bind to a tracked set, got {target:?}");
+    };
     assert_ne!(*target, TargetFilter::Any);
 
     let by_ability = prevent
         .sub_ability
         .as_deref()
         .expect("the source (\"by\") shield must chain as a sequential sibling");
     let Effect::PreventDamage {
         damage_source_filter,
         ..
     } = &*by_ability.effect
     else {
         panic!(
             "expected the source (\"by\") shield, got {:?}",
             by_ability.effect
         );
     };
-    assert!(
-        matches!(damage_source_filter, Some(TargetFilter::TrackedSet { .. })),
-        "the source shield must also bind to the same tracked set, got {damage_source_filter:?}"
-    );
+    let Some(TargetFilter::TrackedSet { id: source_id }) = damage_source_filter else {
+        panic!("the source shield must bind to a tracked set, got {damage_source_filter:?}");
+    };
+    assert_eq!(
+        recipient_id, source_id,
+        "both shields must bind to the SAME tracked set, not independently-created sets"
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// CR 608.2c + CR 615 (issue #6682): Energy Arc's bidirectional "dealt to and
/// dealt by those creatures" must bind BOTH shields to the untapped creatures
/// selected by the preceding clause — a target-derived tracked-set anaphor —
/// not the `Any` fallback that silently protected/exposed every creature.
#[test]
fn energy_arc_bidirectional_prevent_binds_to_untapped_targets() {
let def = parse_effect_chain(
"Untap any number of target creatures. Prevent all combat damage that would be \
dealt to and dealt by those creatures this turn.",
AbilityKind::Spell,
);
let prevent = def
.sub_ability
.as_deref()
.expect("the prevent clause must chain after the untap");
let Effect::PreventDamage { target, .. } = &*prevent.effect else {
panic!(
"expected the recipient (\"to\") shield, got {:?}",
prevent.effect
);
};
assert!(
matches!(target, TargetFilter::TrackedSet { .. }),
"the recipient shield must bind to the untapped-creatures tracked set, got {target:?}"
);
assert_ne!(*target, TargetFilter::Any);
let by_ability = prevent
.sub_ability
.as_deref()
.expect("the source (\"by\") shield must chain as a sequential sibling");
let Effect::PreventDamage {
damage_source_filter,
..
} = &*by_ability.effect
else {
panic!(
"expected the source (\"by\") shield, got {:?}",
by_ability.effect
);
};
assert!(
matches!(damage_source_filter, Some(TargetFilter::TrackedSet { .. })),
"the source shield must also bind to the same tracked set, got {damage_source_filter:?}"
);
}
/// CR 608.2c + CR 615 (issue `#6682`): Energy Arc's bidirectional "dealt to and
/// dealt by those creatures" must bind BOTH shields to the untapped creatures
/// selected by the preceding clause — a target-derived tracked-set anaphor —
/// not the `Any` fallback that silently protected/exposed every creature.
#[test]
fn energy_arc_bidirectional_prevent_binds_to_untapped_targets() {
let def = parse_effect_chain(
"Untap any number of target creatures. Prevent all combat damage that would be \
dealt to and dealt by those creatures this turn.",
AbilityKind::Spell,
);
let prevent = def
.sub_ability
.as_deref()
.expect("the prevent clause must chain after the untap");
let Effect::PreventDamage { target, .. } = &*prevent.effect else {
panic!(
"expected the recipient (\"to\") shield, got {:?}",
prevent.effect
);
};
let TargetFilter::TrackedSet { id: recipient_id } = target else {
panic!("the recipient shield must bind to a tracked set, got {target:?}");
};
assert_ne!(*target, TargetFilter::Any);
let by_ability = prevent
.sub_ability
.as_deref()
.expect("the source (\"by\") shield must chain as a sequential sibling");
let Effect::PreventDamage {
damage_source_filter,
..
} = &*by_ability.effect
else {
panic!(
"expected the source (\"by\") shield, got {:?}",
by_ability.effect
);
};
let Some(TargetFilter::TrackedSet { id: source_id }) = damage_source_filter else {
panic!("the source shield must bind to a tracked set, got {damage_source_filter:?}");
};
assert_eq!(
recipient_id, source_id,
"both shields must bind to the SAME tracked set, not independently-created sets"
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/tests.rs` around lines 49639 - 49685,
Update energy_arc_bidirectional_prevent_binds_to_untapped_targets so it extracts
the TrackedSet id from both the recipient target and damage_source_filter, then
asserts the two ids are equal. Preserve the existing checks that both shields
use TrackedSet and that the recipient is not TargetFilter::Any.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 26 card(s), 14 signature(s) (baseline: main 6719cc549f28)

🟢 Added (3 signatures)

  • 2 cards · ➕ ability/PreventDamage · added: PreventDamage (amount=All, damage_source_filter=opponent controls permanent, scope=AllDamage, target=any target)
    • Affected (first 3): Dovin, Hand of Control, Kiora, the Crashing Wave
  • 2 cards · ➕ ability/PreventDamage · added: PreventDamage (amount=All, damage_source_filter=you control creature, scope=CombatDamage, target=any target)
    • Affected (first 3): Cephalid Illusionist, Soratami Cloud Chariot
  • 1 card · ➕ ability/PreventDamage · added: PreventDamage (amount=All, damage_source_filter=tracked set #0, scope=CombatDamage, target=any target)
    • Affected (first 3): Energy Arc

🟡 Modified fields (11 signatures)

  • 3 cards · 🔄 ability/PreventDamage · changed field target: any targetartifact creature
    • Affected (first 3): Abuna Acolyte, Argivian Blacksmith, Ethersworn Shieldmage
  • 3 cards · 🔄 ability/PreventDamage · changed field target: any targetcreature
    • Affected (first 3): Blinding Fog, Forfend, Kitsune Palliator
  • 3 cards · 🔄 ability/PreventDamage · changed field target: any targetplayer
    • Affected (first 3): Cautery Sliver, Commencement of Festivities, Defend the Hearth
  • 3 cards · 🔄 ability/PreventDamage · changed field target: any targetplayer or planeswalker
    • Affected (first 3): Avacyn, Guardian Angel, Noble Vestige, Wandering Mage
  • 3 cards · 🔄 ability/PreventDamage · changed field target: any targetyou control creature
    • Affected (first 3): Loyal Unicorn, Samite Censer-Bearer, Summon: Alexander
  • 2 cards · 🔄 ability/PreventDamage · changed field target: any targetlegendary creature
    • Affected (first 3): Eiganjo Castle, Kitsune Healer
  • 2 cards · 🔄 ability/PreventDamage · changed field target: any targettracked set #0
    • Affected (first 3): Energy Arc, Mutational Advantage
  • 1 card · 🔄 ability/PreventDamage · changed field target: any targetCleric or creature Wizard
    • Affected (first 3): Wandering Mage
  • 1 card · 🔄 ability/PreventDamage · changed field target: any targetenchanted by self creature
    • Affected (first 3): Fylgja
  • 1 card · 🔄 ability/PreventDamage · changed field target: any targetmulticolored creature
    • Affected (first 3): Brace for Impact
  • 1 card · 🔄 ability/PreventDamage · changed field target: any targettapped Merfolk or tapped creature Kithkin
    • Affected (first 3): Wellgabber Apothecary

@matthewevans matthewevans self-assigned this Jul 29, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested

Reasserted on current head a63ffeb11ef897a95bfe8c48014ed5bbbd425a2f: the prior substantive findings remain unresolved.

  1. [HIGH] Snapshot Mutational Advantage's clause-derived population at resolution, per the official ruling that determines the affected set on resolution; keep bare typed recipients live and add gained-counter/lost-counter runtime coverage.
  2. [MED] Add selected/nonselected Energy Arc cast-pipeline coverage for damage-to and damage-by paths; parser-only tests and hand-built replacement fixtures bypass that pipeline.
  3. [MED] Provide the required full current-head parse-diff artifact for the parser changes.

@matthewevans matthewevans removed their assignment Jul 29, 2026
…solution (phase-rs#6682)

Addresses code review on the prevent-damage recipient-scope fix:

- Snapshot semantics (HIGH): Mutational Advantage's "those permanents" now
  resolves through parse_target's existing TrackedSet dispatch instead of
  binding directly to a live copy of the grant's filter, which would have
  re-checked "has a counter" at every future damage event. The
  chain_prior_mass_population widening (from the prior commit) still feeds
  the runtime: affected_objects_from_events's GenericEffect arm now also
  recognizes Continuous-mode static grants (not just MustAttack coercion),
  so the population gets enumerated once and frozen into the tracked set at
  resolution, matching the official ruling that gained/lost counters after
  resolution don't change who's protected.

- Runtime bug surfaced by the Energy Arc cast-pipeline test (MED): the
  bidirectional "by" shield's ability.targets inherited the preceding Untap
  clause's targets via the chain walker's generic target-propagation, and
  since TargetFilter::Any isn't classified as a context-ref, the shield got
  installed on the untapped creature as a recipient (valid_card: SelfRef)
  instead of routing through the source-scoped path. Extended
  source_scoped_prevent to also recognize TrackedSet/TrackedSetFiltered
  damage_source_filter shapes, mirroring the existing ParentTarget carve-out
  for the Maze of Ith class of bidirectional shields.

- Added a cast-pipeline test for Energy Arc covering selected/nonselected
  damage-to and damage-by, and a cast-pipeline runtime test for Mutational
  Advantage's gained/lost-counter freeze semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claytonlin1110

Copy link
Copy Markdown
Contributor Author

Addressed all three findings:

[HIGH] Clause-derived population must snapshot at resolution, not live-filter. Fixed by routing "those permanents"/"those creatures" uniformly through parse_target's existing TrackedSet dispatch (removed the separate chain_prior_mass_population-bound tier that cloned the grant's live filter directly). The runtime's affected_objects_from_events GenericEffect arm now also recognizes StaticMode::Continuous grants (previously only MustAttack/MustAttackPlayer coercion), so the population is enumerated once and frozen into the tracked set at resolution — matching the official ruling that gaining/losing counters after resolution doesn't change who's protected. Added mutational_advantage_shield_freezes_population_at_resolution, a cast-pipeline runtime test with discriminating gained-counter and lost-counter assertions.

[MED] Energy Arc cast-pipeline coverage. Added energy_arc_cast_pipeline_scopes_to_and_by_damage_to_selected_creature, driven through GameRunner::cast().target_objects(&[selected]), covering both damage-to and damage-by for a selected creature out of two eligible ones. This actually caught a real runtime bug: the bidirectional "by" shield inherited ability.targets from the preceding Untap clause via the chain walker's generic target-propagation, and since TargetFilter::Any isn't classified as a context-ref, the shield got installed on the untapped creature as a recipient (valid_card: SelfRef) instead of routing through the source-scoped path. Fixed by extending source_scoped_prevent to also recognize TrackedSet/TrackedSetFiltered damage_source_filter shapes — mirroring the existing ParentTarget carve-out already in place for the Maze of Ith class of bidirectional shields (same root issue, different filter shape).

[MED] Parse-diff artifact. Checked the CI run directly — the sticky comment is present but shows "baseline pending" (main's coverage snapshot for the base commit hasn't published yet, a CI-timing dependency independent of this PR's content). Pushing this update triggers a fresh CI run, which should produce a populated diff once the baseline catches up.

Full engine test suite (cargo test -p phase-engine --lib, ~18k tests) and clippy -D warnings are clean on the updated branch.

@matthewevans matthewevans self-assigned this Jul 29, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: current head snapshots the resolved population and covers both selected/nonselected cast-pipeline prevention paths.

@matthewevans matthewevans added the bug Bug fix label Jul 29, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 29, 2026
@matthewevans matthewevans removed their assignment Jul 29, 2026
Merged via the queue into phase-rs:main with commit fd53e6a Jul 29, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Card Bug] Mutational Advantage prevents damage to all permanents, not just your countered ones

2 participants