Skip to content

fix(engine): Fight Rigging exiles the targeted creature instead of countering it (#6437) - #6782

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
jsdevninja:fix/6437-fight-rigging-exiled-card-target
Jul 29, 2026
Merged

fix(engine): Fight Rigging exiles the targeted creature instead of countering it (#6437)#6782
matthewevans merged 3 commits into
phase-rs:mainfrom
jsdevninja:fix/6437-fight-rigging-exiled-card-target

Conversation

@jsdevninja

@jsdevninja jsdevninja commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #6437 — Fight Rigging's begin-of-combat trigger ("put a +1/+1 counter on target creature you control. Then if you control a creature with power 7 or greater, you may play the exiled card without paying its mana cost.") exiled the targeted creature instead of the Hideaway-exiled card. Also affects Collector's Cage, which carries the identical counter-then-exiled-card shape on an activated ability.

Root cause 1 — wrong-target propagation

The default sequential-sibling target-propagation arm in resolve_chain_body (has_independent_target_slot, crates/engine/src/game/effects/mod.rs) treats any effect whose target filter reports no dedicated stack-time slot as "safe to inherit the parent's already-chosen object." TargetFilter::ExiledBySource is a context-ref with no slot of its own (it resolves independently via exile_links at its own resolution), so it fell through to that "inherit" branch — copying the counter's chosen creature into the CastFromZone sub's targets. cast_from_zone::resolve then found a non-empty target list and never reached its own ExiledBySourceexile_links fallback, instead treating the targeted creature as the card to license: since the creature sits on the battlefield (not Hand/Graveyard/Exile), grant_lingering_permissions routed it through the exile-delivery batch — exiling it instead of giving it a counter.

Applied the same fix to should_propagate_parent_targets (the sibling authority used by the declined-optional-branch dispatcher and others), refined so a composed filter that ALSO references ParentTarget (Jodah, the Unifier's "put the rest ... except the hit" cleanup) still propagates as before — a plain "exclude whenever ExiledBySource appears anywhere in the filter" first pass regressed that case, caught by the existing jodah_hit_decline_leaves_hit_in_exile_returns_misses unit test.

Root cause 2 — link-kind filtering

Even with (1) fixed, Fight Rigging still failed to find its own hidden card: capture_linked_exile_snapshot (the leaves-the-battlefield ExiledBySource lookup snapshot, read whenever a triggered ability — as opposed to an activated ability like Windbrisk Heights — resolves ExiledBySource) filtered to ExileLinkKind::TrackedBySource only, dropping Hideaway's own ExileLinkKind::HideawayLookable links. HideawayLookable's own doc comment already says the kind is meant to be discoverable by the kind-agnostic ExiledBySource lookup, matching the live (non-snapshot) path, which doesn't filter by kind at all.

Test harness

Added add_enchantment_from_oracle to the scenario builder (mirrors the existing add_land_from_oracle) so a permanent's own triggered ability can be driven directly without routing through its cast/ETB pipeline.

Test plan

  • New regression test issue_6437_fight_rigging_exiled_card_target.rs: targeted creature keeps its counter and stays on the battlefield; the linked hidden card (not the targeted creature) receives the free-cast permission; below the power-7 threshold only the counter is placed.
  • cargo test -p phase-engine --test integration — 4173 passed, 0 failed.
  • cargo test -p phase-engine --lib — 17933 passed, 0 failed (verified the pre-existing bulk_treasure_activation_is_linear_not_factorial perf-counter flake reproduces identically on unmodified main under parallel execution; unrelated to this change).
  • cargo clippy -p phase-engine --all-targets -- -D warnings — clean.
  • cargo fmt --all.

Summary by CodeRabbit

  • Bug Fixes
    • Corrected rules handling for effects that target a source-exiled card, preventing unintended parent-target propagation.
    • Improved linked exile resolution by snapshotting linked cards across all exile link types, fixing mis-routed lookups/permissions in exile-link scenarios.
    • Fixed Fight Rigging so the +1/+1 counter applies to the correct targeted creature and optional permissions for the linked exiled card behave properly.
  • Tests
    • Added integration coverage for Fight Rigging (power threshold met/not met) and real leaves-the-battlefield behavior via Watcher for Tomorrow.
  • New Features
    • Added a scenario helper to create pre-existing enchantments from Oracle text.

…untering it (phase-rs#6437)

Root cause: the default sequential-sibling target-propagation arm in
resolve_chain_body (has_independent_target_slot) treats any effect whose
target filter reports no dedicated slot as "inherit the parent's chosen
object". TargetFilter::ExiledBySource is a context-ref with no slot of its
own (it resolves independently via exile_links), so a chain like "put a
+1/+1 counter on target creature you control. Then ... you may play the
exiled card" copied the counter's chosen creature into the CastFromZone
sub's targets — treating the targeted creature as the card to license and
exiling it instead of countering it. Same fix applied to
should_propagate_parent_targets (the sibling authority used by the
declined-optional-branch dispatcher), refined so a composed filter that
ALSO references ParentTarget (Jodah's "put the rest ... except the hit"
cleanup) still propagates as before.

A second, sibling bug blocked the fix from being observable on Fight
Rigging specifically: capture_linked_exile_snapshot (the leaves-the-
battlefield ExiledBySource lookup used whenever a TRIGGERED ability
resolves ExiledBySource) filtered to ExileLinkKind::TrackedBySource only,
dropping Hideaway's own ExileLinkKind::HideawayLookable links even though
Hideaway's own doc comment says the kind is meant to be found by the
kind-agnostic ExiledBySource lookup, same as the live (non-snapshot) path.

Also affects Collector's Cage, which carries the identical
counter-then-exiled-card shape on an activated ability.

Adds add_enchantment_from_oracle to the scenario test harness (mirrors
add_land_from_oracle) so a permanent's own triggered ability can be driven
without going through its cast/ETB pipeline.
@jsdevninja
jsdevninja requested a review from matthewevans as a code owner July 29, 2026 08:26
@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: 8be1f25b-48ea-4dcf-9715-21da77f22101

📥 Commits

Reviewing files that changed from the base of the PR and between 7b5754e and 9f567dc.

📒 Files selected for processing (1)
  • crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs

📝 Walkthrough

Walkthrough

The engine separates bare ExiledBySource subeffect targets from propagated parent targets, snapshots all linked exiles, and adds Fight Rigging scenario support with integration coverage.

Changes

Fight Rigging exile-target resolution

Layer / File(s) Summary
Separate ExiledBySource target resolution
crates/engine/src/game/effects/mod.rs
Prevents parent-target propagation for independent ExiledBySource filters and recognizes their independent target slots.
Capture all linked exiles
crates/engine/src/game/zones.rs
Snapshots all links matching a source for later ExiledBySource lookups.
Fight Rigging scenario and integration tests
crates/engine/src/game/scenario.rs, crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs, crates/engine/tests/integration/main.rs
Adds enchantment scenario construction and tests conditional counters, casting permissions, targeting, and zones.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GameScenario
  participant FightRigging
  participant ExiledCard
  GameScenario->>FightRigging: create enchantment from Oracle text
  FightRigging->>ExiledCard: resolve ExiledBySource subeffect
  ExiledCard-->>FightRigging: grant conditional free-cast permission
Loading

Possibly related PRs

  • phase-rs/phase#6545: Adjusts related exile-derived target handling and exile-link publication.

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is misleading: the change fixes Fight Rigging so the targeted creature gets a counter and the exiled card gets the free-cast permission. Revise the title to reflect the actual fix, e.g. that Fight Rigging now counters the targeted creature and correctly grants the exiled card permission.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.

@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.

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

1436-1461: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No test exercises the widened link-kind filter.

The existing regression test at Lines 3570-3617 only pushes an ExileLinkKind::TrackedBySource link, so it passes identically before and after this change. Nothing in the diff (or the new Fight Rigging integration tests, which never move the enchantment off the battlefield) proves that a HideawayLookable-kind link is now captured in linked_exile_snapshot on leaves-the-battlefield — the exact behavior this fix claims to add.

Consider adding a case (mirroring the existing test) that pushes an ExileLinkKind::HideawayLookable link and asserts it now appears in record.linked_exile_snapshot after move_to_zone — a case that would have failed under the pre-fix TrackedBySource-only filter.

🤖 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/zones.rs` around lines 1436 - 1461, The regression
coverage does not verify that HideawayLookable links are captured when the
source leaves the battlefield. Extend the existing linked-exile snapshot test
around capture_linked_exile_snapshot and move_to_zone to insert an
ExileLinkKind::HideawayLookable link, then assert it appears in
record.linked_exile_snapshot after the move; preserve the existing
TrackedBySource case.
crates/engine/src/game/effects/mod.rs (1)

2479-2488: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated ExiledBySource-independence predicate.

should_propagate_parent_targets (Lines 2483-2487) and the has_independent_target_slot computation (Lines 10485-10489) both re-implement the identical check: sub.effect.target_filter().is_some_and(TargetFilter::references_exiled_by_source) && !effect_refs_parent_target(&sub.effect). Given how many finely-tuned gating arms already coexist in this function (and how much doc-comment reasoning accompanies each copy), a future edit to one arm risks silently diverging from the other.

♻️ Proposed extraction
+fn is_independent_exiled_by_source_filter(effect: &Effect) -> bool {
+    effect
+        .target_filter()
+        .is_some_and(TargetFilter::references_exiled_by_source)
+        && !effect_refs_parent_target(effect)
+}
+
 fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool {
     sub.targets.is_empty()
         && !ability.targets.is_empty()
         && sub.target_choice_timing != TargetChoiceTiming::Resolution
-        && !(sub
-            .effect
-            .target_filter()
-            .is_some_and(TargetFilter::references_exiled_by_source)
-            && !effect_refs_parent_target(&sub.effect))
+        && !is_independent_exiled_by_source_filter(&sub.effect)
 }
     let has_independent_target_slot =
         (crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some()
             && !effect_refs_parent_target(&sub.effect)
             && !sub_ability_target_belongs_to_reflexive_context(sub))
-            || (sub
-                .effect
-                .target_filter()
-                .is_some_and(TargetFilter::references_exiled_by_source)
-                && !effect_refs_parent_target(&sub.effect));
+            || is_independent_exiled_by_source_filter(&sub.effect);

Also applies to: 10481-10489

🤖 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/effects/mod.rs` around lines 2479 - 2488, Extract the
repeated ExiledBySource-independence check into a shared helper near
should_propagate_parent_targets, then replace the inline predicate in both
should_propagate_parent_targets and the has_independent_target_slot computation
with that helper. Preserve the existing boolean semantics and keep the helper
focused only on target-filter references and effect_refs_parent_target.
🤖 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.

Nitpick comments:
In `@crates/engine/src/game/effects/mod.rs`:
- Around line 2479-2488: Extract the repeated ExiledBySource-independence check
into a shared helper near should_propagate_parent_targets, then replace the
inline predicate in both should_propagate_parent_targets and the
has_independent_target_slot computation with that helper. Preserve the existing
boolean semantics and keep the helper focused only on target-filter references
and effect_refs_parent_target.

In `@crates/engine/src/game/zones.rs`:
- Around line 1436-1461: The regression coverage does not verify that
HideawayLookable links are captured when the source leaves the battlefield.
Extend the existing linked-exile snapshot test around
capture_linked_exile_snapshot and move_to_zone to insert an
ExileLinkKind::HideawayLookable link, then assert it appears in
record.linked_exile_snapshot after the move; preserve the existing
TrackedBySource case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f0a7f4e0-f85d-48ea-b170-864299104e17

📥 Commits

Reviewing files that changed from the base of the PR and between 694200e and f55f0be.

📒 Files selected for processing (5)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/scenario.rs
  • crates/engine/src/game/zones.rs
  • crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs
  • crates/engine/tests/integration/main.rs

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@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 — the Fight Rigging target-propagation regression is discriminating, but one separately broadened runtime path remains untested.

🔴 Blocker

[MED] The all-link-kind leave-the-battlefield snapshot has no production-path regression. Evidence: crates/engine/src/game/zones.rs:1445-1460 changes capture_linked_exile_snapshot from TrackedBySource-only to every ExileLinkKind, but crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs:101-177 keeps Fight Rigging on the battlefield throughout, so it never invokes that snapshot function. Why it matters: the new behavior governs a different path — an exiled-card lookup after the source has left the battlefield — and a future change could reintroduce kind filtering or snapshot the wrong link set while this PR remains green. Suggested fix: add a runtime leaves-the-battlefield witness that moves the source through the zone pipeline and proves the subsequent ExiledBySource consumer receives the intended Hideaway-linked card (and preserves the stated all-kind contract), or remove this unrelated snapshot widening from this PR.

✅ Clean

The Fight Rigging witness reaches the real begin-combat trigger, proves the counter target stays on the battlefield, and proves the linked hidden card — not that target — receives the permission.

Recommendation: request changes for the missing snapshot-path discriminator; the target-propagation fix itself is correctly scoped.

@matthewevans matthewevans added the bug Bug fix label Jul 29, 2026
@matthewevans matthewevans removed their assignment Jul 29, 2026
jsdevninja and others added 2 commits July 29, 2026 05:00
…ath (phase-rs#6437)

Address review: the kind-widening fix in capture_linked_exile_snapshot
(TrackedBySource-only -> every ExileLinkKind) had no test that actually
drives a source through the real leaves-the-battlefield zone pipeline —
the two existing Fight Rigging tests keep the source on the battlefield
throughout, exercising a different (still-present) call path.

Adds a regression test for Watcher for Tomorrow's real LTB trigger ("When
this creature leaves the battlefield, put the exiled card into its owner's
hand", Effect::ChangeZoneAll targeting ExiledBySource): casts a real
removal spell to destroy it through the actual casting/zone pipeline
(zones::move_to_zone), then asserts the HideawayLookable-linked hidden
card reaches its owner's hand. Verified as a genuine discriminator by
temporarily reverting the kind filter back to TrackedBySource-only and
confirming the new test fails (hidden card stranded in exile) before
re-applying the fix.
@matthewevans

Copy link
Copy Markdown
Member

Maintainer re-review — held only for current-head checks.

At 9f567dc235aa19f8734528ace62a22ea82c9cdbe, the new Watcher for Tomorrow scenario drives a real destroy/leave-the-battlefield trigger and proves a HideawayLookable linked card reaches its owner's hand. That directly resolves the prior snapshot-path test gap; the current parse-diff is no changes, and the new source review has no remaining correctness finding.

Rust checks are still running for this head. This is not a request for contributor changes; once they settle, the handler will clear the stale changes-requested state and continue approval/enqueue handling.

@jsdevninja

Copy link
Copy Markdown
Contributor Author

Addressed — added `watcher_for_tomorrow_leaving_the_battlefield_finds_the_hideaway_linked_card`, which casts a real removal spell to destroy Watcher for Tomorrow through the actual casting/zone pipeline (`zones::move_to_zone`), driving the genuine leaves-the-battlefield path `capture_linked_exile_snapshot` guards, and asserts the `HideawayLookable`-linked hidden card reaches its owner's hand via Watcher's own parsed LTB trigger (`Effect::ChangeZoneAll`).

Verified as a real discriminator: temporarily reverted the kind filter back to `TrackedBySource`-only and confirmed this new test fails (hidden card stranded in `Exile` instead of reaching `Hand`) before re-applying the fix.

@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 verified.

The Watcher for Tomorrow scenario exercises the real LTB zone-move path and verifies the Hideaway linked-exile card is recovered. The current parse diff is unchanged and required CI is green.

@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 899e509 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.

fight rigging — Exiles the targeted creature instead of putting a counter on it.

2 participants