Skip to content

fix(engine): granted "leave the battlefield → exile instead" riders must actually fire (Geth, Realmbreaker, Elemental Expressionist)#6580

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
michiot05:fix/granted-leave-battlefield-replacement
Jul 24, 2026
Merged

fix(engine): granted "leave the battlefield → exile instead" riders must actually fire (Geth, Realmbreaker, Elemental Expressionist)#6580
matthewevans merged 1 commit into
phase-rs:mainfrom
michiot05:fix/granted-leave-battlefield-replacement

Conversation

@michiot05

@michiot05 michiot05 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Tier: Frontier
Model: claude-opus-4-8

Closes #6566 (filed at your request during the #6538 review).

Summary

A quoted grant of a replacement-shaped ability parsed to an inert ContinuousModification::GrantAbility: the concretized body was pushed into obj.abilities and never into replacement_definitions, so the redirect silently did nothing. Geth, Thane of Contracts"Return target creature card from your graveyard to the battlefield. It gains "If this creature would leave the battlefield, exile it instead of putting it anywhere else."" — reanimated a creature that then went to the graveyard on any battlefield exit.

Adds ContinuousModification::GrantReplacement, the replacement-store sibling of GrantAbility/GrantTrigger/GrantStaticAbility (CR 614 is a categorically distinct sink from CR 602/603/604). It is applied at layer 6 with structural dedup and re-derived every layer pass (CR 613.1f), so its lifetime follows the granting continuous effect rather than an object-lifetime stamp:

  • Permanent grants (Geth, Llanowar Greenwidow, Realmbreaker, Spirit-Sister's Call) persist until the host leaves (CR 611.2a), surviving the granting source itself leaving.
  • Duration-bound grants — Elemental Expressionist's "Until end of turn, it gains …" — lapse at cleanup (CR 514.2).
  • A grant carrying other modifications alongside the replacement is never promoted to permanent. Expressionist grants two abilities ([GrantReplacement, GrantTrigger]), so that guard is load-bearing and has its own revert-failing regression.

This is deliberately not #6538's host-lifetime stamp — that path is for standalone (non-granted) riders and is unchanged here; the two now share one ReplacementDefinition constructor.

Blocker fixed along the way (the feature is a silent no-op without it): normalize_card_name_refs rewrites this creature|permanent|land~ card-wide (CR 201.5b), but parse_leave_battlefield_rider_ref had no ~ arm, so every quoted grant failed to parse before reaching the classifier. The added arm is purely additive; I confronted all 74 corpus cards containing "would leave the battlefield" and no standalone rider uses a this <type> subject, so the #6538 front door is provably unaffected.

Relationship to #5762 (disclosed — please tell me how you'd like this sequenced)

@ntindle's open #5762 (Play historic from graveyard once/turn + granted leave-battlefield replacement rider) addresses The Eighth Doctor at a different seam: it threads an optional ReplacementDefinition through StaticMode's play-from-graveyard permission. Mine routes quoted grants through the classifier into a layer-6 GrantReplacement.

They look complementary rather than competing — The Eighth Doctor is explicitly out of scope here precisely because its whole clause is Effect::Unimplemented { name: "static_structure" } at the permission seam #5762 fixes, so it never reaches my classifier and does not appear in my parse-diff. That said, the two branches touch add_target_replacement.rs and oracle_effect/mod.rs, so they will conflict textually. Happy to rebase onto whichever lands first, or to hold this until #5762 resolves — your call; I did not want two granted-replacement lifecycle changes sitting in the queue without flagging it.

Implementation method (required)

Method: /engine-implementer — plan → /review-engine-plan (3 rounds; round 1 corrected the card set and identified Geth Thane of Contracts as the real card, round 2 caught the ~ no-op blocker, round 3 resolved which cards actually flip) → implement → /review-impl (3 rounds; round 1 required the Expressionist EOT-lapse regression, the final pass required the mixed-def guard test) → commit. Each step in a fresh agent context. Final read-only /review-impl: PASS head=054b54d30d76eccd659b71fb01f2763576b3353d.

Gate A

./scripts/check-parser-combinators.shGate A PASS head=054b54d30d76eccd659b71fb01f2763576b3353d (Gate G PASS). The only parser change is one tag("~") arm composed into an existing alt — no string probes.

Anchored on

  • ContinuousModification::GrantTrigger (types/ability.rs) + its Layer-6 apply (game/layers.rs) — the sibling grant that lands in a different store (obj.trigger_definitions); GrantReplacement mirrors it exactly onto obj.replacement_definitions, with the GrantStaticAbility structural-dedup push as the idempotency idiom.
  • game/layers.rs base→live reseed — the store is reset to base every pass, which is why the layer-native design needs no stamp, no base-install, and no host-exit prune (the grant's own duration governs, via prune_end_of_turn_effects / prune_affected_object_left_effects).
  • game/mod.rs game::zones — the documented #[cfg(any(test, feature = "test-support"))] pub / #[cfg(not(…))] pub(crate) precedent reused verbatim to expose the shared constructor to the integration-test crate (the feature is enabled only from [dev-dependencies]; cargo tree -e normal shows engine FEATURES=[]).

Verification

  • cargo fmt --all clean; cargo clippy -p engine --tests 0 warnings; cargo build -p engine -p phase-ai clean.
  • Integration (crates/engine/tests/integration/issue_6566_granted_leave_exile.rs, registered in main.rs) — 5 passed: Geth verbatim-Oracle reanimate → destroy → exile (with an un-granted control creature → graveyard as the paired reach guard); permanent grant survives end-of-turn cleanup; duplicate grants dedup to one definition; Expressionist-shaped until-end-of-turn grant exiles same turn and lapses at cleanup (post-cleanup destroy → graveyard); mixed [GrantReplacement, GrantTrigger] def is not promoted to permanent.
  • Parser/unit — the ~ rider row, the it-subject no-regression row, classify_quoted_innerGrantReplacement, a non-rider quoted body → not GrantReplacement, layer classification, and a byte-identity assertion that the shared constructor still emits fix(engine): unearth's leave-battlefield exile replacement must survive layer resets (Rotting Rats + Sacrifice) #6538's exact ReplacementDefinition + AddTargetReplacement { target: Any } wrapper.
  • Fails before, passes after — demonstrated three times: reverting the tag("~") arm fails the parser row and all integration rows; dropping the && duration.is_none() conjunct fails only the Expressionist EOT row (the three Geth rows still pass — which is exactly why that regression was added); swapping the mixed-def .all.any fails only the mixed-def row.
  • CR annotations — every number grep-verified against docs/MagicCompRules.txt: 614.1a, 614.6, 613.1f, 611.2a, 514.2, 201.5b, 702.84a.
  • Exhaustive ContinuousModification arms added at all 10 wildcard-free match sites (compile-finalized, including 3 the compiler surfaced beyond the planned list).

Claimed parse impact

Regenerated card-data.json on this head and counted occurrences of the new variant (brand-new, so every occurrence is a flip): exactly 5 cardselemental expressionist, geth, thane of contracts, llanowar greenwidow, realmbreaker, the invasion tree, spirit-sister's call. Explicitly verified absent: Magar of the Magic Strings and The Eighth Doctor (both swallowed upstream by Effect::Unimplemented, so untouched by this change) and Geth, Lord of the Vault (no such rider on that printing — my original issue text mis-named it; corrected in #6566).

Scope Expansion

None.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for granted replacement effects, including “If this would leave the battlefield, exile it instead” riders.
    • Granted replacement payloads are now correctly discovered, layered as ability effects, and preserved on recipients (including self-reference behavior).
  • Bug Fixes
    • Improved handling of ~/self-reference variants in replacement parsing.
    • Prevented duplicate hosted replacements and corrected permanent vs end-of-turn duration promotion.
    • Ensured replacement-related scanning/coverage and X-references are not missed.
  • Tests
    • Added integration and unit tests covering parsing, layering, deduplication, replacement outcomes, and duration edge cases.

@michiot05
michiot05 requested a review from matthewevans as a code owner July 24, 2026 02:36
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

📝 Walkthrough

Walkthrough

Adds GrantReplacement support for parsed leave-battlefield exile riders, including representation, layer application, duration handling, deduplication, traversal, coverage, and integration tests.

Changes

Object-hosted replacement grants

Layer / File(s) Summary
Replacement grant contract and parsing
crates/engine/src/types/..., crates/engine/src/parser/..., crates/engine/src/database/unearth.rs
Adds the GrantReplacement variant, parses normalized ~ self-references, shares the leave-battlefield exile replacement constructor, and updates parser behavior and tests.
Layer evaluation and duration
crates/engine/src/game/effects/effect.rs, crates/engine/src/game/layers.rs
Registers granted replacements with per-definition duration handling and structurally deduplicates replacement definitions on objects.
Traversal and coverage support
crates/engine/src/analysis/..., crates/engine/src/game/..., crates/phase-ai/src/policies/x_reference.rs
Traverses nested replacement definitions across analysis, legacy detection, coverage, printed-card scanning, quantity classification, and X-reference detection.
End-to-end replacement behavior
crates/engine/tests/integration/issue_6566_granted_leave_exile.rs, crates/engine/tests/integration/main.rs
Tests replacement installation, zone-change redirection, deduplication, permanent fallback duration, and end-of-turn expiration.

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

Possibly related issues

Possibly related PRs

Suggested labels: bug

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant Parser
  participant LayerEngine
  participant GameObject
  participant ZoneChange
  OracleText->>Parser: parse leave-battlefield exile rider
  Parser->>LayerEngine: create GrantReplacement
  LayerEngine->>GameObject: install deduplicated replacement
  ZoneChange->>GameObject: apply hosted replacement
  GameObject->>ZoneChange: redirect battlefield exit to exile
Loading
🚥 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 accurately summarizes the main engine fix and names representative affected cards.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

@michiot05
michiot05 force-pushed the fix/granted-leave-battlefield-replacement branch from 31d2d44 to 054b54d Compare July 24, 2026 02:37
@michiot05 michiot05 changed the title fix(engine): granted "if it would leave the battlefield, exile it instead" riders must actually fire (Geth, Realmbreaker, Elemental Expressionist) fix(engine): granted "leave the battlefield → exile instead" riders must actually fire (Geth, Realmbreaker, Elemental Expressionist) Jul 24, 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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/analysis/ability_graph.rs (1)

1883-1897: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Make the ContinuousModification match exhaustive.

The new arm is correct, but the trailing _ => {} still hides future variants from ability-graph traversal. Enumerate the remaining intentional no-op variants explicitly so adding another nested ability carrier fails compilation until this visitor is updated.

As per path instructions, known enums must use exhaustive matches without wildcard fallbacks.

🤖 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/analysis/ability_graph.rs` around lines 1883 - 1897, Update
the ContinuousModification match in the ability-graph traversal to remove the
wildcard fallback and explicitly list every remaining intentional no-op variant.
Preserve the existing GrantAbility, GrantTrigger, and GrantReplacement handling,
ensuring future enum variants cause a compilation failure until this visitor is
updated.

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/game/coverage.rs`:
- Around line 6934-6937: Update GrantReplacement handling to traverse both
ReplacementDefinition branches: execute and ReplacementMode::Optional { decline
}. In crates/engine/src/game/coverage.rs at lines 6934-6937, 4534-4538,
5912-5915, and 6010-6014, include decline in static support, parsed-child
emission, unimplemented-part checks, and missing-part collection; in
crates/engine/src/game/ability_rw.rs at lines 2698-2703 and 2767-2773, include
decline in legacy-reference traversal and replacement-helper inspection; and in
crates/phase-ai/src/policies/x_reference.rs at lines 170-172, detect X
references in decline as well as execute.

In `@crates/engine/src/parser/oracle_static/tests.rs`:
- Around line 32133-32136: Update
classify_quoted_inner_grants_leave_battlefield_exile_6566 to pass the original
Oracle wording through normalize_card_name_refs or the production parser before
classification, rather than supplying an already normalized "~" string. Assert
the resulting GrantReplacement so the test covers normalization of “this
creature/permanent/land” and the full production path.

In `@crates/engine/src/types/layers.rs`:
- Around line 114-116: Correct the comment above
ContinuousModification::GrantReplacement to cite CR 613.1f for its Layer 6
assignment, replacing the inaccurate CR 614.6 reference. If replacement
semantics are mentioned, use CR 614.1 or CR 614.1a instead.

In `@crates/engine/tests/integration/issue_6566_granted_leave_exile.rs`:
- Around line 174-177: Remove the misleading “Dedup guard” comment and the
redundant evaluate_layers(runner.state_mut()) call in this test, since this
location cannot observe duplicate grants and duplicate behavior is covered by
duplicate_grants_dedup_to_one_replacement. Retain the corpse binding only if it
remains necessary for test clarity or compilation.

---

Outside diff comments:
In `@crates/engine/src/analysis/ability_graph.rs`:
- Around line 1883-1897: Update the ContinuousModification match in the
ability-graph traversal to remove the wildcard fallback and explicitly list
every remaining intentional no-op variant. Preserve the existing GrantAbility,
GrantTrigger, and GrantReplacement handling, ensuring future enum variants cause
a compilation failure until this visitor is updated.
🪄 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: ec1a5b19-0f42-48f4-a769-df770586f97c

📥 Commits

Reviewing files that changed from the base of the PR and between bb58f44 and 054b54d.

📒 Files selected for processing (20)
  • crates/engine/src/analysis/ability_graph.rs
  • crates/engine/src/database/unearth.rs
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/effects/effect.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/game/quantity.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_static/keyword_grant.rs
  • crates/engine/src/parser/oracle_static/shared.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/layers.rs
  • crates/engine/tests/integration/issue_6566_granted_leave_exile.rs
  • crates/engine/tests/integration/main.rs
  • crates/phase-ai/src/policies/x_reference.rs

Comment on lines +6934 to +6937
ContinuousModification::GrantReplacement { replacement } => replacement
.execute
.as_deref()
.is_none_or(is_ability_supported),

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 | ⚡ Quick win

Traverse both executable branches of ReplacementDefinition.

All these paths inspect only execute and ignore ReplacementMode::Optional { decline }, causing false-green support/coverage results and incomplete legacy/X analysis.

  • crates/engine/src/game/coverage.rs#L6934-L6937: include decline support when computing is_static_supported.
  • crates/engine/src/game/coverage.rs#L4534-L4538: emit the decline ability as a parsed child.
  • crates/engine/src/game/coverage.rs#L5912-L5915: inspect decline for unimplemented parts.
  • crates/engine/src/game/coverage.rs#L6010-L6014: collect missing parts from decline.
  • crates/engine/src/game/ability_rw.rs#L2698-L2703: traverse decline for legacy references.
  • crates/engine/src/game/ability_rw.rs#L2767-L2773: extend the replacement helper to inspect decline.
  • crates/phase-ai/src/policies/x_reference.rs#L170-L172: detect X references in decline.

As per path instructions, reusable replacement-definition handling must cover the complete GrantReplacement contract, not only the current mandatory rider.

📍 Affects 3 files
  • crates/engine/src/game/coverage.rs#L6934-L6937 (this comment)
  • crates/engine/src/game/coverage.rs#L4534-L4538
  • crates/engine/src/game/coverage.rs#L5912-L5915
  • crates/engine/src/game/coverage.rs#L6010-L6014
  • crates/engine/src/game/ability_rw.rs#L2698-L2703
  • crates/engine/src/game/ability_rw.rs#L2767-L2773
  • crates/phase-ai/src/policies/x_reference.rs#L170-L172
🤖 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/coverage.rs` around lines 6934 - 6937, Update
GrantReplacement handling to traverse both ReplacementDefinition branches:
execute and ReplacementMode::Optional { decline }. In
crates/engine/src/game/coverage.rs at lines 6934-6937, 4534-4538, 5912-5915, and
6010-6014, include decline in static support, parsed-child emission,
unimplemented-part checks, and missing-part collection; in
crates/engine/src/game/ability_rw.rs at lines 2698-2703 and 2767-2773, include
decline in legacy-reference traversal and replacement-helper inspection; and in
crates/phase-ai/src/policies/x_reference.rs at lines 170-172, detect X
references in decline as well as execute.

Source: Path instructions

Comment thread crates/engine/src/parser/oracle_static/tests.rs
Comment on lines +114 to +116
// CR 613.1f + CR 614.6: granting an object-hosted replacement is an
// ability-adding effect (Layer 6), beside GrantTrigger.
| ContinuousModification::GrantReplacement { .. }

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Correct the CR citation for the layer mapping.

CR 614.6 describes the event after a replacement is applied; it does not justify assigning this modification to Layer 6. Use CR 613.1f for the layer assignment and CR 614.1/CR 614.1a for replacement-effect semantics instead. (media.wizards.com)

As per path instructions, rules-touching code must use a CR citation whose rule body describes the implementation.

🤖 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/types/layers.rs` around lines 114 - 116, Correct the
comment above ContinuousModification::GrantReplacement to cite CR 613.1f for its
Layer 6 assignment, replacing the inaccurate CR 614.6 reference. If replacement
semantics are mentioned, use CR 614.1 or CR 614.1a instead.

Sources: Path instructions, MCP tools

Comment on lines +174 to +177
// Dedup guard: exactly one granted Moved/SelfRef def (a second evaluate_layers
// pass re-derives from base and must not accumulate).
evaluate_layers(runner.state_mut());
let _ = corpse; // corpse id == reanimated identity; retained for clarity.

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The "dedup guard" comment overclaims — this block asserts nothing. The comment states "exactly one granted Moved/SelfRef def … must not accumulate," but no assert follows the second evaluate_layers, and by this point reanimated is already in Zone::Exile, so a re-derivation on the battlefield set can't be observed here anyway. The actual dedup is covered by duplicate_grants_dedup_to_one_replacement. Either drop the misleading comment/evaluate_layers call or move a real leave_exile_replacement_count(...) == 1 assertion to a point where reanimated is still on the battlefield.

🤖 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/tests/integration/issue_6566_granted_leave_exile.rs` around
lines 174 - 177, Remove the misleading “Dedup guard” comment and the redundant
evaluate_layers(runner.state_mut()) call in this test, since this location
cannot observe duplicate grants and duplicate behavior is covered by
duplicate_grants_dedup_to_one_replacement. Retain the corpse binding only if it
remains necessary for test clarity or compilation.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 5 card(s), 6 signature(s) (baseline: main b2628643ad65)

🟢 Added (3 signatures)

  • 2 cards · ➕ ability/grant replacement · added: grant replacement (affects=parent target, grants=grant replacement, target=parent target)
    • Affected (first 3): Geth, Thane of Contracts, Realmbreaker, the Invasion Tree
  • 2 cards · ➕ ability/grant replacement · added: grant replacement (affects=self, grants=grant replacement)
    • Affected (first 3): Llanowar Greenwidow, Spirit-Sister's Call
  • 1 card · ➕ ability/grant replacement, grant trigger · added: grant replacement, grant trigger (affects=parent target, duration=until end of turn, grants=grant replacement, grants=grant trigger, target=parent target)
    • Affected (first 3): Elemental Expressionist

🔴 Removed (3 signatures)

  • 2 cards · ➖ ability/grant ability · removed: grant ability (affects=parent target, grants=grant ability, target=parent target)
    • Affected (first 3): Geth, Thane of Contracts, Realmbreaker, the Invasion Tree
  • 2 cards · ➖ ability/grant ability · removed: grant ability (affects=self, grants=grant ability)
    • Affected (first 3): Llanowar Greenwidow, Spirit-Sister's Call
  • 1 card · ➖ ability/grant ability, grant trigger · removed: grant ability, grant trigger (affects=parent target, duration=until end of turn, grants=grant ability, grants=grant trigger, target=parent target)
    • Affected (first 3): Elemental Expressionist

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

…ust actually fire

A quoted grant of a replacement-shaped ability — Geth, Thane of
Contracts' 'It gains "If this creature would leave the battlefield,
exile it instead of putting it anywhere else"' — parsed to an inert
ContinuousModification::GrantAbility. The concretized body was pushed
into obj.abilities and never into replacement_definitions, so the
redirect silently did nothing and the reanimated creature went to the
graveyard on any battlefield exit.

Adds ContinuousModification::GrantReplacement, the replacement-store
sibling of GrantAbility/GrantTrigger/GrantStaticAbility (CR 614 is a
categorically distinct sink from CR 602/603/604). It is applied at
layer 6 with structural dedup and re-derived every layer pass
(CR 613.1f), so its lifetime is governed by the granting continuous
effect rather than an object-lifetime stamp: a permanent grant survives
its source leaving, and a temporary one (Elemental Expressionist's
"Until end of turn") lapses at cleanup (CR 611.2a, CR 514.2). A grant
carrying other modifications alongside the replacement is never
promoted to permanent — Expressionist grants two abilities, so that
guard is load-bearing and is pinned by its own regression.

Also fixes the parser gap that made the rider unreachable at this seam:
normalize_card_name_refs rewrites "this creature|permanent|land" to
"~" card-wide (CR 201.5b), but parse_leave_battlefield_rider_ref had
no "~" arm, so every quoted grant failed to parse. The standalone
rider path (phase-rs#6538) is unaffected — no standalone printing uses a
"this <type>" subject.

The Moved→Exile ReplacementDefinition is built by one shared,
deliberately UNSTAMPED constructor (leave_battlefield_exile_replacement,
expiry: None) with three consumers that decide the lifetime themselves:

  * the standalone parser rider (try_parse_leave_battlefield_exile_
    replacement) and unearth's synthesis (CR 702.84a) each compose
    .expiry(UntilHostLeavesPlay) — reproducing phase-rs#6538's host-lifetime
    stamp byte-for-byte (CR 400.7), so phase-rs#6538's base-install /
    non-copiable / host-exit-prune machinery is preserved exactly; and

  * the granted path (classify_quoted_inner → GrantReplacement) uses the
    parser fn purely as a DETECTOR and grants the UNSTAMPED constructor
    output. A granted replacement's lifetime is governed by the grant's
    duration and re-derived every layer pass (CR 611.2a / CR 613.1f); a
    host-lifetime stamp would be read by phase-rs#6538's
    is_runtime_host_lifetime_replacement (base-install + non-copiable +
    host-exit-prune), base-installing the granted rider so it would
    outlive the granting continuous effect and Expressionist's
    until-end-of-turn grant would never lapse. The reconciliation is
    pinned by granted_replacement_is_not_host_lifetime_stamped (RED if
    the granted def is ever re-stamped) and the byte-identity test
    leave_battlefield_exile_replacement_matches_6538_shape (RED if the
    standalone shape drifts from phase-rs#6538).

Closes phase-rs#6566

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@michiot05
michiot05 force-pushed the fix/granted-leave-battlefield-replacement branch from 293afbe to 54fa471 Compare July 24, 2026 05:34
@michiot05

Copy link
Copy Markdown
Contributor Author

Heads up — I force-pushed a clean rebase onto current main (54fa471de), which replaced your Merge origin/main commit (293afbea0). Apologies for overwriting your shepherding commit; flagging exactly what happened and why, and happy to defer to your preference on how the branch is updated going forward.

The CI red here wasn't just a stale test — it's a genuine semantic conflict with our own #6538, which merged after this branch was cut. #6538 now stamps .expiry(UntilHostLeavesPlay) inside try_parse_leave_battlefield_exile_replacement, the shared parser fn this PR routes through. A straight merge main (your 293afbea0) still failed the same 2 checks, because the granted path would then inherit that host-lifetime stamp — and #6538's machinery keys base-install off exactly that stamp (is_runtime_host_lifetime_replacement), so a granted def would be base-installed and outlive the grant, breaking Elemental Expressionist's until-end-of-turn lapse. That's the opposite of what this PR is for.

The rebase resolves it by splitting the lifetime decision per consumer:

Pinned by a new revert-failing guard, granted_replacement_is_not_host_lifetime_stamped, so a future refactor can't silently re-stamp the granted path. Branch is now linear on main, all engine lib + issue_6566 integration tests pass locally, and the 5-card parse-diff is unchanged (elemental expressionist, geth, thane of contracts, llanowar greenwidow, realmbreaker, spirit-sister's call). Gate A/G PASS.

If you'd rather drive branch updates yourself (or prefer I never force-push over a maintainer commit), just say the word and I'll work to whatever process you want.

Model: claude-opus-4-8

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

Reviewed at head 54fa471dec40. This is excellent work, and it lands at the right seam. Approving.

What I verified (not just read in the description)

  • The seam is correct. GrantReplacement is applied in the layer-6 continuous-modification loop (game/layers.rs) into obj.replacement_definitions with a structural-dedup idempotency guard, exactly mirroring the GrantStaticAbility/AddStaticMode siblings. The "no lifetime stamp needed" argument holds because replacement_definitions is reseeded from base_replacement_definitions every pass (layers.rs:1692, pre-existing) — so the grant's persistence is governed by whether the continuous effect still applies that pass, not an object-lifetime stamp. This is genuinely distinct from #6538's host-lifetime path and correctly leaves it untouched.
  • The promotion guard is right and well-fenced. effect.rs::resolve promotes to Duration::Permanent only when duration_from_fallback && !modifications.is_empty() && modifications.iter().all(GrantReplacement). The !is_empty() conjunct stops an empty list from vacuously satisfying .all(), and the .all() predicate is what keeps Expressionist's mixed [GrantReplacement, GrantTrigger] from being promoted. CR 611.2a lifetime semantics, applied correctly.
  • The tests discriminate. mixed_replacement_and_trigger_grant_is_not_promoted_to_permanent is genuinely revert-sensitive — swapping .all.any promotes the mixed def and reds only that row. The Geth test drives the runtime pipeline (reanimate → destroy → exile) with a paired un-granted control creature going to the graveyard as the reach guard, which is what proves the replacement is doing the work rather than a blanket behavior. This is exactly the discriminating-test bar.
  • CR citations verified against docs/MagicCompRules.txt: 611.2a (continuous-effect lifetime), 614.1a ("instead" = replacement), 613.1f (Layer 6 ability-adding), 201.5b (name→gained-ability rewrite, which is what the ~ blocker fix rests on), 514.2 (EOT cleanup). All accurate.
  • The parse-diff proves generality. 5 cards flip in lockstep from grant abilitygrant replacement (Geth, Realmbreaker, Llanowar Greenwidow, Spirit-Sister's Call, Elemental Expressionist). This is a class fix, not a card patch.

The ~ blocker writeup (that normalize_card_name_refs rewrites this creature/permanent/land~ card-wide, so the rider had to grow a ~ arm to parse at all) is a nice catch, and confronting all 74 "would leave the battlefield" corpus cards to show no standalone rider uses a this <type> subject is the right way to prove the #6538 front door is unaffected.

On sequencing with #5762

Approving and enqueuing this answers your sequencing question: this lands first, and #5762 rebases onto it. Your scoping is correct — The Eighth Doctor is Effect::Unimplemented at the permission seam #5762 fixes, never reaches this classifier, and doesn't appear in your parse-diff, so the two are complementary rather than competing. The only overlap is textual (add_target_replacement.rs, oracle_effect/mod.rs); the queue's own CI will surface any conflict if #5762 is retargeted before this merges. No need to hold.

Nice work — the seam reasoning, the three revert-failing regressions, and parameterizing GrantReplacement as a member of the existing grant family (rather than special-casing the redirect) are exactly what I want to see.

@matthewevans matthewevans added bug Bug fix quality For high-quality minimal to no-churn PRs labels Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit 2975e41 Jul 24, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Quoted grants of replacement-shaped abilities ("if it would leave the battlefield, exile it instead") parse to an inert GrantAbility and never fire

2 participants