Skip to content

fix(engine): a player-directed "exile a creature they control" is a choice, not a target (Strategic Betrayal)#6607

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
michiot05:fix/strategic-betrayal-possessive-exile-6505
Jul 25, 2026
Merged

fix(engine): a player-directed "exile a creature they control" is a choice, not a target (Strategic Betrayal)#6607
matthewevans merged 5 commits into
phase-rs:mainfrom
michiot05:fix/strategic-betrayal-possessive-exile-6505

Conversation

@michiot05

@michiot05 michiot05 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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: Graveyard onto the creature leg (which should be Battlefield). Origin is now inferred from the primary-clause slice only — a small compound_exile_origin_scan helper that trims a trailing and <conjunct> before infer_origin_zone, applied uniformly across the three compound-exile sites (imperative.rs, and the single- and mass-exile arms in oracle_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 stamped TargetChoiceTiming::Resolution, making it a non-targeted, resolution-time player-directed choice (no BecomesTarget → no Ward). At resolution, an ability with exactly one TargetRef::Player target and a ScopedPlayer-scoped move binds scoped_player to that target player — a sibling of the engine's existing trigger-event scoped_player stamp — 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 existing scoped_player channel.

No new enum variant (ControllerRef::ScopedPlayer, TargetChoiceTiming::Resolution, scoped_player all exist). controller_for_relative_filter, sacrifice.rs, and rebind_owned_scope are untouched — the actor binding keys on the ScopedPlayer scope produced only by this arm, so a caster-subject "you exile a creature target opponent controls" (which yields a TargetOpponent-controller filter, not ScopedPlayer) 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_scope extension and a runtime-invisible blast-radius net, round 3 re-seated Part B on the ScopedPlayer/scoped_player actor channel instead of keying the shared controller_for_relative_filter on a moved-object filter controller) → implement (tests-first RED→GREEN) → /review-impl (Maintainer-Simulation Gate: PASS, all six executor deviations validated sound, the leadership_vacuum assertion change confirmed a legitimate generalization not a masked regression). Each step in a fresh agent context.

Gate A

./scripts/check-parser-combinators.shGate A PASS head=2e9db496d (Gate G PASS). Part A is arithmetic slicing of nom remainders (the conjunct trim uses tag("and ")); Part B is typed scope/timing/actor stamping. No string-dispatch introduced.

Anchored on

  • Sacrifice edict (game/effects/sacrifice.rs::resolve_sacrifice_scope, CR 109.4) — the proven "target player is the actor/chooser" precedent; this fix reuses the same scoped_player binding for the exile flavor rather than duplicating it.
  • stack.rs resolve_top — the existing trigger-event scoped_player stamp (DamageDealt/AttackersDeclared referent); the new single-TargetRef::Player stamp is a sibling in the same seam, so the target player flows to the resolution-time chooser via the unchanged controller_for_relative_filter ScopedPlayer branch.
  • oracle_effect/lower.rs::change_zone_selects_battlefield_permanent (promoted pub(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 --all clean; cargo clippy -p engine --tests 0 warnings; cargo test -p engine --lib 17,648 passed / 0 failed; cargo test -p engine --test integration -- strategic_betrayal_6505 10 passed.
  • Integration (crates/engine/tests/integration/strategic_betrayal_6505.rs, registered in main.rs) — all through the real cast/stack pipeline (runner.cast(...).target_player(...).resolve()), verbatim Oracle text, each negative reach-guarded:
    • End-to-end: opponent controls two creatures (one Ward—{2}) + graveyard cards → no 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, zero Effect::Unimplemented, exiles actually resolved.)
    • Per-mechanism revert-sensitive isolation: reverting only the timing stamp re-fires Ward (left: Stack, right: Resolution); reverting only the scoped_player stamp flips the chooser back to the caster; reverting only the origin slice makes the eligible set the graveyard instead of the battlefield.
    • Negatives: "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 graveyard stays a stack target; the mass compound stays coverage-honest fail-closed, no silent orphan).
  • CR annotations grep-verified against 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.json baseline in this worktree to compute the exact Part-A parse-diff count offline, so I've left that to CI — the coverage-parse-diff artifact will report the full affected set; I expect it to be the compound-exile family only, with the full test-engine suite 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_scope untouched.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed “Strategic Betrayal” so the opponent (“they”) is correctly scoped for choosing the exiled creature.
    • Ensured “exiles a creature they control and their graveyard” selects the creature at resolution (not as a target), preventing incorrect Ward/payment timing.
    • Improved parsing of Exile text so “and their graveyard” no longer leaks graveyard origin onto the battlefield leg.
    • Corrected moved-object scoping/origin handling for “their graveyard”-style patterns.
  • Tests

    • Added/expanded Strategic Betrayal integration and parser-shape assertions, including targeted/sibling regression coverage.

…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>
@michiot05
michiot05 requested a review from matthewevans as a code owner July 24, 2026 21:53
@coderabbitai

coderabbitai Bot commented Jul 24, 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: 273f96b7-965f-484b-a05f-5a01ac811915

📥 Commits

Reviewing files that changed from the base of the PR and between a323232 and 8f26be9.

📒 Files selected for processing (6)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/stack.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/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/parser/oracle_effect/mod.rs

📝 Walkthrough

Walkthrough

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

Changes

Strategic Betrayal resolution flow

Layer / File(s) Summary
Exile parsing and resolution-choice lowering
crates/engine/src/parser/oracle_effect/..., crates/engine/src/parser/oracle_effect/imperative.rs
Exile origin inference excludes trailing compound conjuncts, controller scope is rebound according to the selection mode, and battlefield resolution picks receive Resolution choice timing.
Resolution-scoped player propagation
crates/engine/src/game/effects/mod.rs, crates/engine/src/game/stack.rs
Relative-controller-scoped filters are detected through ability chains, and resolution seeds scoped_player from the ability’s player target.
Parser and runtime regression coverage
crates/engine/src/parser/oracle_tests.rs, crates/engine/tests/integration/...
Tests verify chooser routing, resolution timing, battlefield-only eligibility, graveyard exile, Ward behavior, preserved caster choice semantics, origin inference, and fail-closed parsing.

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
Loading

Suggested reviewers: matthewevans

🚥 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 is specific, concise, and accurately summarizes the Strategic Betrayal resolver fix.
Linked Issues check ✅ Passed The changes match #6505 by making the opponent choose the creature at resolution so Ward no longer triggers.
Out of Scope Changes check ✅ Passed The additional parser tweaks and regression tests are tied to the same scoped-choice bug and do not appear unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

ast-grep timed out on this file


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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_effect/mod.rs (1)

31767-31775: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Enforce the "rem is a suffix of base" 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 of base. The three current call sites are fine, but parse_exile_ast has an owned rem_lower and a truncated _rem sitting a few lines from the call — an easy wrong argument on a future edit. strip_suffix expresses the intent and fails closed.

As per coding guidelines, "Use strip_prefix and strip_suffix instead of starts_with or ends_with followed 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2c6596d and 2e9db49.

📒 Files selected for processing (8)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/stack.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_tests.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/strategic_betrayal_6505.rs

Comment thread crates/engine/src/parser/oracle_effect/mod.rs Outdated
Comment on lines +19072 to +19079
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,
};

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 | 🟠 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/parser

Repository: 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.rs

Repository: 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 expanded

Repository: 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

Comment thread crates/engine/tests/integration/strategic_betrayal_6505.rs Outdated
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 15 card(s), 7 signature(s) (baseline: main dddd313222ff)

🟡 Modified fields (7 signatures)

  • 6 cards · 🔄 ability/ChangeZone · changed field from: graveyard
    • Affected (first 3): Dragon's Approach, Silent Gravestone, Spellweaver Volute (+3 more)
  • 3 cards · 🔄 ability/ChangeZone · changed field target: you control creaturescoped player controls creature
    • Affected (first 3): Debt to the Kami, Doomfall, Strategic Betrayal
  • 3 cards · 🔄 ability/ChangeZoneAll · changed field from: graveyard
    • Affected (first 3): Once More with Feeling, Shadows' Verdict, Ultimate Nullification
  • 2 cards · 🔄 ability/ChangeZone · changed field target: you control enchantmentscoped player controls enchantment
    • Affected (first 3): Debt to the Kami, Early Winter
  • 1 card · 🔄 ability/ChangeZone · changed field from: library
    • Affected (first 3): Gilded Ambusher
  • 1 card · 🔄 ability/ChangeZone · changed field target: nontoken you control creaturenontoken scoped player controls creature
    • Affected (first 3): Azula, Cunning Usurper
  • 1 card · 🔄 ability/ChangeZoneAll · changed field target: commander in battlefield youcommander in battlefield scoped player
    • Affected (first 3): Leadership Vacuum

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

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

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.

@matthewevans matthewevans added the bug Bug fix label Jul 24, 2026
… 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>

@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 (1)
crates/engine/src/parser/oracle_effect/mod.rs (1)

7553-7586: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Replace the wildcard with explicit terminal variants.
_ => {} hides new TargetFilter variants from the compiler; handle the current terminal cases explicitly and keep recursion only on And/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

📥 Commits

Reviewing files that changed from the base of the PR and between 2e9db49 and b35b2a8.

📒 Files selected for processing (2)
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/tests/integration/strategic_betrayal_6505.rs

@matthewevans matthewevans self-assigned this Jul 25, 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.

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

Maintainer re-review: approved at 8f26be9. Current-main integration adds the explicit LastZoneChanged terminal only; recursive and mutating behavior is unchanged.

@matthewevans matthewevans removed their assignment Jul 25, 2026
@michiot05

Copy link
Copy Markdown
Contributor Author

Thanks — this was a real gap in my accounting; addressed at head b35b2a86c. The regenerated parse-diff is now 15 cards / 7 signatures (down from 25 / 14).

🔴 HIGH — parser scope + full blast radius

Root cause of the over-broad diff: I was pinning ctx.relative_player_scope = Some(ScopedPlayer) around the entire lower_imperative_clause call, which re-scoped every predicate under a "target player …" subject — including non-ChangeZone ones that have no runtime scoped_player binding (so TargetFilter::ScopedPlayer would have resolved to None). That was both the unexplained blast radius and a latent bug.

Narrowed to a targeted post-lowering rewrite: only when the lowered leg is the moved-object ChangeZone/ChangeZoneAll battlefield resolution-pick whose "they control" defaulted to ControllerRef::You do I rewrite that leg's controller You → ScopedPlayer (via a small rebind_controller_scope), gated on the predicate actually containing "they control". rebind_owned_scope, controller_for_relative_filter, and sacrifice.rs are untouched. The 11 non-ChangeZone predicates you'd expect — Consume/Consumed by Greed/Will of the Abzan/Riveteers Charm (Sacrifice), Linessa (Bounce), Hunted Nightmare (PutCounter), Shadrix Silverquill (PutCounterAll), Early Harvest (UntapAll), plus Blot Out/End of the Hunt — all revert to baseline and drop out of the diff.

The remaining 15 cards, accounted:

Part B — target-player-directed move (you control → scoped player controls), 6 cards. Each is a genuine "target player {exiles/returns} a permanent they control" subject, where the target player is the CR 109.4 actor and ScopedPlayer is correct: Strategic Betrayal, Debt to the Kami (creature + enchantment modes), Doomfall (creature mode), Early Winter (enchantment mode), Azula, Cunning Usurper (nontoken creature), Leadership Vacuum (mass ChangeZoneAll — "each commander they control"). A caster-subject shape ("you exile a creature target opponent controls") has controller = TargetOpponent, not You, and no "they control" anaphor, so it is doubly excluded from this rewrite and stays caster-chosen.

Part A — origin de-leak (from: graveyard/library → ∅), 10 cards. The compound-origin scan only fires when the target-parser remainder begins with " and …", i.e. the graveyard/library keyword lived entirely in a trimmed trailing conjunct and the primary leg had no zone phrase of its own. This is a strict fix, not a regression, because when origin is set, add_inferred_origin_constraints_to_target injects an InZone constraint that mis-scopes candidate selection to the private zone for class-enumerating (Typed/Or) filters:

Card primary leg ‖ trimmed conjunct base→head why ∅ is correct
Strategic Betrayal a creature they controland their graveyard Gy→∅ primary is a battlefield creature; base wrongly InZone(Graveyard)-scoped it (the reported bug)
The Spot, Living Portal up to one target nonland permanentand … from a graveyard Gy→∅ primary leg is a battlefield permanent
Shadows' Verdict all creatures/PWs … from the battlefieldand … from all graveyards Gy→∅ primary explicitly battlefield
Once More with Feeling all permanentsand all cards from all graveyards Gy→∅ "all permanents" = battlefield
Ultimate Nullification all creaturesand graveyards Gy→∅ "all creatures" = battlefield
Gilded Ambusher a nonland, nontoken permanent they controland a random nonland card from their library Lib→∅ primary is a battlefield permanent
Dragon's Approach exile Dragon's Approachand four cards … from your graveyard Gy→∅ SelfRef = the resolving spell on the stack; base origin=Graveyard guard would have made the exile silently no-op — ∅ fixes it; SelfRef selects by object id, not zone, so no battlefield-copy risk
Silent Gravestone exile this artifactand all cards from all graveyards Gy→∅ SelfRef on battlefield; origin never affects selection
Spellweaver Volute exile the enchanted cardand attach … in a graveyard Gy→∅ ParentTarget selects by object id; resolver reads its actual zone; guard was redundant
Underworld Cerberus exile itand each player returns … from their graveyard Gy→∅ TriggeringSource selects by object id; harmless

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 change_zone.rs: from_zone comes from the object's actual zone; origin is only an optional skip-guard.) A few of these compounds separately drop their trimmed graveyard-leg as a pre-existing fail-closed coverage gap; Part A doesn't worsen them.

🔴 MED — compound_exile_origin_scan manual slice

Fixed — now base.strip_suffix(rem).filter(|_| trailing_and_conjunct) (fail-closed; a non-suffix rem can no longer panic).

🟡 Non-blocking — vacuous hostile fixture

Fixed — the explicit-target fixture's creature-leg lookup is now a required expect(...).

Added tests: a runtime sibling (sibling_bare_target_player_exile_they_control_binds_chooser_no_ward — opponent is chooser, own creature never offered, Ward silent), leadership_vacuum_mass_leg_is_scopedplayer, and origin_leak_compound_exile_primary_leg_stays_battlefield. cargo test -p engine --lib 17,648 pass; integration 13/13; clippy 0; Gate A green.

@matthewevans
matthewevans added this pull request to the merge queue Jul 25, 2026
@matthewevans

Copy link
Copy Markdown
Member

Re-review complete — no new finding on the current approved head.

✅ Clean

The unchanged head 8f26be98382d5c0e8f02346c13f9d48723233747 remains covered by the prior approval. The author’s complete 15-card/7-signature accounting matches the current <!-- coverage-parse-diff --> artifact, including the scoped-player and origin de-leak classes.

Recommendation: retain the existing approval and merge when the queue’s required checks complete.

Merged via the queue into phase-rs:main with commit b3bd4f9 Jul 25, 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.

Strategic Betrayal — Ward triggers when opponent chooses their own creature

2 participants