fix(engine): a player-directed "exile a creature they control" is a choice, not a target (Strategic Betrayal)#6607
Conversation
…hoice, not a target (Strategic Betrayal)
Strategic Betrayal ("Target opponent exiles a creature they control and
their graveyard.") triggered Ward on the opponent's own creature: the
"exile a creature they control" clause resolved as a TARGETED exile with
the wrong actor, instead of a resolution-time choice the target opponent
makes. Per CR 115.10a/b a non-"target" object is not targeted (Ward, CR
702.21a, must not fire) and CR 109.4 makes the target player the actor.
Two coupled defects, fixed at their seams:
- Parser origin leak: a trailing "and their graveyard" conjunct leaked
origin:Graveyard onto the creature leg. Origin is now inferred from the
primary clause slice only (compound_exile_origin_scan), applied uniformly
across the three compound-exile sites.
- Target-vs-choice + actor: the "target player exiles ..." arm now pins the
anaphoric "they control" scope to ScopedPlayer (unifying the creature and
graveyard legs) and stamps Resolution timing on the battlefield pick, so
it is a non-targeted player-directed choice. At resolution, a single
Player-target ability with a ScopedPlayer-scoped move binds scoped_player
to the target player (sibling of the existing trigger-event stamp), so the
target opponent is the chooser and their own graveyard is exiled.
Reuses existing mechanisms only (no new enum variant); controller_for_relative_filter,
sacrifice.rs, and rebind_owned_scope are untouched. The fix generalizes to
Leadership Vacuum ("target player returns each commander they control").
Closes phase-rs#6505
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughStrategic Betrayal parsing and resolution now preserve relative-player scoping, classify its creature choice as resolution-time selection, and prevent Ward from triggering on that choice. Parser, runtime, and integration tests cover chooser attribution, eligible zones, graveyard exile, and related regressions. ChangesStrategic Betrayal resolution flow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant OracleText
participant OracleParser
participant resolve_top
participant EffectZoneChoice
participant Ward
OracleText->>OracleParser: parse relative-controller exile clauses
OracleParser->>OracleParser: mark creature choice for Resolution
OracleParser->>resolve_top: provide scoped filters and player target
resolve_top->>EffectZoneChoice: offer battlefield creatures to scoped player
EffectZoneChoice->>resolve_top: return chosen creature
resolve_top->>Ward: resolve without treating creature as a target
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.44.1)crates/engine/src/parser/oracle_effect/mod.rsast-grep timed out on this file Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_effect/mod.rs (1)
31767-31775: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winEnforce the "
remis a suffix ofbase" precondition instead of documenting it.
base[..base.len() - rem.len()]panics (length underflow, or a mid-UTF-8 boundary) the moment a caller passes a remainder that is not a suffix ofbase. The three current call sites are fine, butparse_exile_asthas an ownedrem_lowerand a truncated_remsitting a few lines from the call — an easy wrong argument on a future edit.strip_suffixexpresses the intent and fails closed.As per coding guidelines, "Use
strip_prefixandstrip_suffixinstead ofstarts_withorends_withfollowed by manual slicing".♻️ Make a non-suffix `rem` fail closed rather than panic
pub(super) fn compound_exile_origin_scan(base: &str, rem: &str) -> String { let rem_head = rem.trim_start(); let trailing_and_conjunct = tag::<_, _, OracleError<'_>>("and ").parse(rem_head).is_ok(); - if trailing_and_conjunct { - base[..base.len() - rem.len()].to_ascii_lowercase() - } else { - base.to_ascii_lowercase() - } + match base.strip_suffix(rem).filter(|_| trailing_and_conjunct) { + Some(primary) => primary.to_ascii_lowercase(), + // Not a trailing conjunct — or `rem` is not a suffix of `base` + // (caller contract broken): scan the full base rather than slice. + None => base.to_ascii_lowercase(), + } }🤖 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/mod.rs` around lines 31767 - 31775, Update compound_exile_origin_scan to derive the shortened base using strip_suffix(rem) when trailing_and_conjunct is true, returning the lowercased base only when the suffix exists and otherwise failing closed without slicing or panicking. Preserve the existing lowercase behavior for non-trailing conjunctions.Source: Coding guidelines
🤖 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/mod.rs`:
- Around line 19072-19079: Update the creature_leg_is_resolution_pick
calculation around change_zone_selects_battlefield_permanent to determine
targeting from the parsed clause structure rather than scan_contains_phrase on
pred_lower. Reuse the existing structured target-detection representation for
the clause, preserving the current battlefield-selection check and ensuring
Ward/shroud/hexproof/protection timing follows parsed targeting semantics.
- Around line 18918-18948: Narrow the ScopedPlayer override around
lower_imperative_clause so it applies only when the resulting clause is the
moved-object ChangeZone/ChangeZoneAll wrapper that has the resolution
scoped_player stamp. Do not pin ScopedPlayer for unrelated predicate shapes,
which must retain their normal controller resolution; alternatively, provide an
explicit controller fallback in resolve_effect_player_ref for this path.
In `@crates/engine/tests/integration/strategic_betrayal_6505.rs`:
- Around line 564-570: Make the control test fail when the expected ChangeZone
leg is absent by replacing the optional if-let handling around
find_change_zone(root) with an expect-style lookup, using the same “creature
ChangeZone leg” guard as Test 8. Keep the existing target_choice_timing
assertion unchanged after obtaining the creature.
---
Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 31767-31775: Update compound_exile_origin_scan to derive the
shortened base using strip_suffix(rem) when trailing_and_conjunct is true,
returning the lowercased base only when the suffix exists and otherwise failing
closed without slicing or panicking. Preserve the existing lowercase behavior
for non-trailing conjunctions.
🪄 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: 7dce93ca-e203-4891-84bf-62b8c626848b
📒 Files selected for processing (8)
crates/engine/src/game/effects/mod.rscrates/engine/src/game/stack.rscrates/engine/src/parser/oracle_effect/imperative.rscrates/engine/src/parser/oracle_effect/lower.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/parser/oracle_tests.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/strategic_betrayal_6505.rs
| let creature_leg_is_resolution_pick = match &clause.effect { | ||
| Effect::ChangeZone { origin, target, .. } | ||
| | Effect::ChangeZoneAll { origin, target, .. } => { | ||
| lower::change_zone_selects_battlefield_permanent(*origin, target) | ||
| && !scan_contains_phrase(&pred_lower, "target ") | ||
| } | ||
| _ => false, | ||
| }; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bash
#!/bin/bash
# Prove or deny detector drift: what does the claimed authority actually test?
ast-grep run --lang rust --pattern 'fn target_choice_timing_for_clause($$$) { $$$ }' crates/engine/src/parser/oracle_effect/lower.rs
# The typed discriminator this should key on instead.
rg -nP -C5 'enum TargetSyntax|fn parse_target_with_syntax' crates/engine/src/parser
# Every other place that decides targeted-vs-resolution from a text scan of "target ".
rg -nP -C3 'scan_contains_phrase\([^,]+,\s*"target ' crates/engine/src/parserRepository: phase-rs/phase
Length of output: 4134
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the helper the comment claims this logic should mirror.
ast-grep run --lang rust --pattern 'fn target_choice_timing_for_clause($$$) { $$$ }' crates/engine/src/parser/oracle_effect/lower.rs
# Read the surrounding implementation and comments.
line=$(rg -n 'fn target_choice_timing_for_clause' crates/engine/src/parser/oracle_effect/lower.rs | head -n1 | cut -d: -f1)
start=$((line-20))
end=$((line+120))
sed -n "${start},${end}p" crates/engine/src/parser/oracle_effect/lower.rs
# Check the typed target syntax path for the exact discriminator semantics.
line2=$(rg -n 'pub enum TargetSyntax|pub fn parse_target_with_syntax' crates/engine/src/parser/oracle_target.rs | head -n1 | cut -d: -f1)
start2=$((line2-10))
end2=$((line2+80))
sed -n "${start2},${end2}p" crates/engine/src/parser/oracle_target.rsRepository: phase-rs/phase
Length of output: 152
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the helper and inspect nearby code.
rg -n -C 40 'target_choice_timing_for_clause|scan_contains_phrase\([^,]+,\s*"target ' crates/engine/src/parser/oracle_effect/lower.rs crates/engine/src/parser/oracle_effect/mod.rs crates/engine/src/parser/oracle_target.rs
# If the helper exists under a slightly different name, list nearby identifiers.
ast-grep outline crates/engine/src/parser/oracle_effect/lower.rs --view expandedRepository: phase-rs/phase
Length of output: 42826
Use structured target detection here instead of scanning the predicate text.
scan_contains_phrase(&pred_lower, "target ") decides whether this battlefield-selecting ChangeZone becomes a target, so Ward/shroud/hexproof/protection timing can flip on a raw text substring. Key this off parsed clause shape, not rendered Oracle text.
🤖 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/mod.rs` around lines 19072 - 19079,
Update the creature_leg_is_resolution_pick calculation around
change_zone_selects_battlefield_permanent to determine targeting from the parsed
clause structure rather than scan_contains_phrase on pred_lower. Reuse the
existing structured target-detection representation for the clause, preserving
the current battlefield-selection check and ensuring
Ward/shroud/hexproof/protection timing follows parsed targeting semantics.
Sources: Coding guidelines, Path instructions
Parse changes introduced by this PR · 15 card(s), 7 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Current-head verdict: blocked on parser scope and targeting evidence.
🔴 Blocker
[HIGH] The required parse-diff reports 25 cards changed across 14 signatures, but the PR body and runtime coverage substantiate Strategic Betrayal only. Evidence: the current <!-- coverage-parse-diff --> comment lists changes to Dragon's Approach, Debt to the Kami, Leadership Vacuum, Early Harvest, and many others; the new integration module is strategic_betrayal_6505.rs. Why it matters: this parser-scope expansion can alter unrelated targeting and zone behavior without a card-by-card claim or discriminating regression. Suggested fix: either narrow the parser change to the intended grammar or explain and test each affected card class against the measured artifact.
[MED] compound_exile_origin_scan slices a presumed suffix manually. Evidence: crates/engine/src/parser/oracle_effect/mod.rs:31767-31774 uses base[..base.len() - rem.len()]. Suggested fix: use base.strip_suffix(rem) before truncating, so a future caller cannot turn a violated internal precondition into a panic.
🟡 Non-blocking
The explicit-target hostile fixture is vacuous when no creature leg is found: strategic_betrayal_6505.rs:564-570 only asserts inside if let Some(...). Make that lookup required.
Recommendation: request changes; account for the full parse blast radius and close the parser/test gaps before approval.
… leg; account for parse blast radius (phase-rs#6505 review) The Strategic Betrayal fix pinned `relative_player_scope = ScopedPlayer` around the ENTIRE `lower_imperative_clause` call, so it re-scoped every "they control" predicate — including NON-ChangeZone effects (Sacrifice / PutCounter / PutCounterAll / UntapAll / bounce-repeat) that the runtime `scoped_player` stamp does not cover (`ability_uses_relative_controller_scoped` only matches ChangeZone/ChangeZoneAll). Those parse changes were unintended and latently broken (`ScopedPlayer` with no resolution-time binding). Narrowing (replaces the whole-clause parse-time pin): - Remove the `relative_player_scope = ScopedPlayer` save/restore around the predicate lowering. The predicate now lowers with its natural scope. - Apply the `ScopedPlayer` rebind POST-lowering to the moved-object leg ALONE: when the lowered leg is a battlefield resolution-pick ChangeZone/ChangeZoneAll (`creature_leg_is_resolution_pick`) whose anaphoric "they control" lowered to the default `ControllerRef::You`, rewrite ONLY that leg's `You` controller to `ScopedPlayer` (new `rebind_controller_scope(from, to)` general helper). Gated on the "they control" anaphor so a "you control" caster leg (also `You`) is never mis-rebound. The Resolution-timing stamp stays gated on `creature_leg_is_resolution_pick`, unchanged. `rebind_owned_scope`, `controller_for_relative_filter`, and `sacrifice.rs` are untouched. Effect: the Sacrifice / PutCounter / PutCounterAll / UntapAll / bounce-repeat predicates keep their original scope and DROP OUT of the parse-diff. Verified by a baseline(192897c)-vs-head focused parse harness over the full named blast radius: every non-ChangeZone card is `ScopedPlayer=0` on both sides (Consecrate//Consume, Consumed by Greed, Will of the Abzan, Riveteers Charm, Linessa Zephyr Mage, Hunted Nightmare, Shadrix Silverquill, Early Harvest). Accounted parse-diff (all correct, no regressions): - Part B (target-player "exile/move/return a permanent they control" → the moved-object leg is ScopedPlayer + Resolution): Strategic Betrayal, Debt to the Kami (both modes), Doomfall (creature mode), Early Winter (enchantment mode), Azula Cunning Usurper, Leadership Vacuum (mass "each commander they control"). Blot Out / End of the Hunt do NOT change — their "greatest mana value" superlative is a context-ref filter (not a resolution-pick), so they revert to the pre-existing (unchanged) baseline scope. - Part A (origin de-leak, `from: graveyard/library → ∅`): the trailing "and <graveyard/library conjunct>" no longer leaks its zone onto the primary battlefield/self leg. `∅` is a correct, safer parse: for a specific object the resolver keys on the object's actual zone and treats `origin` as an optional guard (`Some(x)` that mismatches → skip), so `∅` = "no constraint" exiles from the real zone — and actually FIXES Silent Gravestone (baseline `origin=Graveyard` would have skipped exiling its own battlefield artifact). Verified correct for Silent Gravestone, Spellweaver Volute, Gilded Ambusher, and (full-pipeline) Once More with Feeling, Shadows' Verdict, Ultimate Nullification. Dragon's Approach stays Unimplemented (no surfaced change). Also in this commit: - `compound_exile_origin_scan`: replace the manual `base[..base.len()-rem.len()]` slice (panics on a non-suffix / mid-UTF-8 `rem`) with a fail-closed `base.strip_suffix(rem).filter(|_| trailing_and_conjunct)`. - strategic_betrayal_6505.rs hostile fixture: make the creature-leg lookup REQUIRED (`expect`) so it can't pass vacuously. - New discriminating tests: a bare "target player exiles a creature they control" sibling runtime test (opponent is chooser, correct scope, no Ward); a Leadership Vacuum mass-leg ScopedPlayer shape test; and an origin-leak class test (Silent Gravestone's "exile this artifact and all cards from all graveyards" — primary leg stays battlefield origin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_effect/mod.rs (1)
7553-7586: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winReplace the wildcard with explicit terminal variants.
_ => {}hides newTargetFiltervariants from the compiler; handle the current terminal cases explicitly and keep recursion only onAnd/Or/Not.🤖 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/mod.rs` around lines 7553 - 7586, Update rebind_controller_scope to replace the wildcard match arm with explicit handling for every current non-composite terminal TargetFilter variant. Keep controller and Owned-property rebinding in Typed, recursion only for And, Or, and Not, and use no catch-all arm so future variants produce compile-time errors.Sources: Coding guidelines, 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.
Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 7553-7586: Update rebind_controller_scope to replace the wildcard
match arm with explicit handling for every current non-composite terminal
TargetFilter variant. Keep controller and Owned-property rebinding in Typed,
recursion only for And, Or, and Not, and use no catch-all arm so future variants
produce compile-time errors.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6567026c-9888-449e-92b9-db9beb8bcca7
📒 Files selected for processing (2)
crates/engine/src/parser/oracle_effect/mod.rscrates/engine/tests/integration/strategic_betrayal_6505.rs
matthewevans
left a comment
There was a problem hiding this comment.
Maintainer review: approved at a323232. The scoped-player/origin class coverage and the exhaustive TargetFilter fix are clean; no outstanding actionable current-head findings.
matthewevans
left a comment
There was a problem hiding this comment.
Maintainer re-review: approved at 8f26be9. Current-main integration adds the explicit LastZoneChanged terminal only; recursive and mutating behavior is unchanged.
|
Thanks — this was a real gap in my accounting; addressed at head 🔴 HIGH — parser scope + full blast radiusRoot cause of the over-broad diff: I was pinning Narrowed to a targeted post-lowering rewrite: only when the lowered leg is the moved-object The remaining 15 cards, accounted: Part B — target-player-directed move ( Part A — origin de-leak (
No card in the set has a primary exile leg that genuinely needs graveyard/library candidate-zone scoping — that's the only shape that would make the de-leak a regression — so nothing was narrowed there. (Confirmed against 🔴 MED —
|
|
Re-review complete — no new finding on the current approved head. ✅ CleanThe unchanged head Recommendation: retain the existing approval and merge when the queue’s required checks complete. |
Tier: Frontier
Model: claude-opus-4-8
Closes #6505.
Summary
Strategic Betrayal — "Target opponent exiles a creature they control and their graveyard." — triggered Ward on the opponent's own creature. The spell targets only the opponent (CR 115.1a); the opponent then chooses (does not target) a creature they control to exile, plus exiles their graveyard. Per CR 115.10a/115.10b an object affected but not identified by "target" is not a target, so Ward (CR 702.21a) must not fire, and CR 109.4 makes the target player the actor/chooser. The engine was resolving "exile a creature they control" as a targeted exile with the caster as actor.
Two coupled defects, each fixed at its seam:
1. Parser origin-inference leak (parse-visible). The compound-exile origin was inferred from the whole post-verb text, so the trailing "and their graveyard" conjunct leaked
origin: Graveyardonto the creature leg (which should be Battlefield). Origin is now inferred from the primary-clause slice only — a smallcompound_exile_origin_scanhelper that trims a trailingand <conjunct>beforeinfer_origin_zone, applied uniformly across the three compound-exile sites (imperative.rs, and the single- and mass-exile arms inoracle_effect/mod.rs). For any non-compound clause it is byte-identical to the previous behavior.2. Target-vs-choice + actor binding. Even with origin fixed, the creature leg was emitted as a stack target with the caster as actor. In the "target player exiles …" arm the anaphoric "they control" scope is now pinned to
ScopedPlayer(unifying the creature leg with the already-ScopedPlayer"their graveyard" leg), and the battlefield pick is stampedTargetChoiceTiming::Resolution, making it a non-targeted, resolution-time player-directed choice (noBecomesTarget→ no Ward). At resolution, an ability with exactly oneTargetRef::Playertarget and aScopedPlayer-scoped move bindsscoped_playerto that target player — a sibling of the engine's existing trigger-eventscoped_playerstamp — so the target opponent is the chooser and their own graveyard is exiled. This mirrors the sacrifice-edict actor rule (CR 109.4) using the existingscoped_playerchannel.No new enum variant (
ControllerRef::ScopedPlayer,TargetChoiceTiming::Resolution,scoped_playerall exist).controller_for_relative_filter,sacrifice.rs, andrebind_owned_scopeare untouched — the actor binding keys on theScopedPlayerscope produced only by this arm, so a caster-subject "you exile a creature target opponent controls" (which yields aTargetOpponent-controller filter, notScopedPlayer) is never stamped and stays caster-chosen.The fix generalizes to the class: Leadership Vacuum ("target player returns each commander they control …") now correctly returns the target player's commanders (was the caster's) via the same
ScopedPlayer+ single-Player-target stamp.Implementation method (required)
Method: /engine-implementer — plan (/engine-planner, with a run-then-delete reproduction probe confirming the Ward stall) → /review-engine-plan (three adversarial rounds; round 1 corrected the sibling-exile site coverage, round 2 rejected an over-broad
rebind_owned_scopeextension and a runtime-invisible blast-radius net, round 3 re-seated Part B on theScopedPlayer/scoped_playeractor channel instead of keying the sharedcontroller_for_relative_filteron a moved-object filter controller) → implement (tests-first RED→GREEN) → /review-impl (Maintainer-Simulation Gate: PASS, all six executor deviations validated sound, theleadership_vacuumassertion change confirmed a legitimate generalization not a masked regression). Each step in a fresh agent context.Gate A
./scripts/check-parser-combinators.sh→ Gate A PASS head=2e9db496d (Gate G PASS). Part A is arithmetic slicing of nom remainders (the conjunct trim usestag("and ")); Part B is typed scope/timing/actor stamping. No string-dispatch introduced.Anchored on
game/effects/sacrifice.rs::resolve_sacrifice_scope, CR 109.4) — the proven "target player is the actor/chooser" precedent; this fix reuses the samescoped_playerbinding for the exile flavor rather than duplicating it.stack.rsresolve_top— the existing trigger-eventscoped_playerstamp (DamageDealt/AttackersDeclared referent); the new single-TargetRef::Playerstamp is a sibling in the same seam, so the target player flows to the resolution-time chooser via the unchangedcontroller_for_relative_filterScopedPlayerbranch.oracle_effect/lower.rs::change_zone_selects_battlefield_permanent(promotedpub(super), reused not duplicated) — the single authority for "is this a battlefield, non-targeted pick"; it gates both the Resolution-timing stamp and the rebind-skip off one shared boolean, so timing and controller can never diverge.Verification
cargo fmt --allclean;cargo clippy -p engine --tests0 warnings;cargo test -p engine --lib17,648 passed / 0 failed;cargo test -p engine --test integration -- strategic_betrayal_650510 passed.crates/engine/tests/integration/strategic_betrayal_6505.rs, registered inmain.rs) — all through the real cast/stack pipeline (runner.cast(...).target_player(...).resolve()), verbatim Oracle text, each negative reach-guarded:UnlessPayment/Ward prompt; the opponent is the chooser and is offered its two battlefield creatures; the chosen creature is exiled; the opponent's graveyard is exiled. (reach-guards: parse OK, zeroEffect::Unimplemented, exiles actually resolved.)left: Stack, right: Resolution); reverting only thescoped_playerstamp flips the chooser back to the caster; reverting only the origin slice makes the eligible set the graveyard instead of the battlefield."Exile target creature."still triggers Ward (not over-suppressed);"exile a creature you control …"keeps the caster as chooser;"Target player sacrifices a creature."(Diabolic Edict) still binds the chooser to the target player (sacrifice path unchanged); parser-shape + hostile fixtures (exile target … and their graveyardstays a stack target; the mass compound stays coverage-honest fail-closed, no silent orphan).docs/MagicCompRules.txt: 115.1a, 115.10a, 115.10b, 702.21a, 109.4, 608.2c, 701.13a, 400.7.Claimed parse impact
Intentional behavior changes: Strategic Betrayal and Leadership Vacuum (same class — target-player-directed "they control"). Part A's origin fix is byte-identical to prior behavior for every non-compound exile clause and only re-scopes the origin of compound "exile X and <zone-conjunct>" clauses. I did not have a committed
card-data.jsonbaseline in this worktree to compute the exact Part-A parse-diff count offline, so I've left that to CI — thecoverage-parse-diffartifact will report the full affected set; I expect it to be the compound-exile family only, with the fulltest-enginesuite green. Happy to reconcile against the artifact.Scope Expansion
None. Leadership Vacuum is the same target-player-directed class as Strategic Betrayal (the class the fix is defined over), not an unrelated surface.
controller_for_relative_filter,sacrifice.rs,rebind_owned_scopeuntouched.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests