fix(parser): derive activation zone from a self-move's origin, not its destination - #7316
Conversation
…s destination
CR 113.6m restricts an activated ability to the zone its effect moves the
source *out of*; the rule says nothing about where the card goes.
`activation_zone_from_self_effect` additionally required
`destination: Zone::Battlefield`, so a self-move to any other zone never
derived a zone at all and fell back to the CR 113.6 battlefield default.
Bestial Bloodline's `{4}{G}: Return this card from your graveyard to your
hand.` was therefore offered while the Aura sat on the battlefield, and
withheld while the card sat in the graveyard. Both halves were wrong.
55 abilities across 55 cards gain `activation_zone: Graveyard` (measured
against card-data at MTGJSON 2026-08-10); no other ability changes in any
direction. The 21 Craft abilities keep `activation_zone: None` — they are
synthesized in `database/synthesis.rs` and never reach this derivation, and
their battlefield-zoned self-exile cost resolves them the same way regardless.
Kogla and Yidaro keeps `Hand`: its "Discard this card" cost outranks the
effect-side origin under CR 113.6j, which the `.or_else()` order encodes and
a new test pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-ups; no production behavior change (the `oracle.rs` delta is doc-comment only, so parse output is provably unchanged). - CR 702.83 is Exalted; unearth is CR 702.84. Corrected the citation in `activation_zone_from_self_effect`'s doc-comment. - The battlefield-negative that survives a revert of the production line is the Aura's `!offers_activation` assertion; the other runtime rows abort earlier on their `activation_zone` precondition, which itself flips. That assertion carried the whole runtime claim with no reach-guard. Added revert-invariant guards — ability shape, empty restrictions, attachment, and affordability via `can_pay_cost_after_auto_tap` against the ability's own parsed cost. Deliberately no `activation_zone` assertion there: it would abort the test before the discriminating line and destroy exactly the property being protected. A comment says so. - The `origin != Zone::Battlefield` guard's doc-comment claimed Cooped Up and Cage of Hands as its class. Both are rejected earlier — on `target` and on the effect variant respectively — and no ability in the corpus carries a battlefield-origin self-`ChangeZone` (measured: 0 of 22,794). Rewrote it to state the guard's real, currently-empty class and name the shape that would reach it. The guard stays; it is a correct CR 113.6 default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…seam Final review-impl finding. The positive row asserted `activation_zone` before `offers_activation`, so on a revert of the production line it aborted on the parser value — which `oracle_tests.rs` already covers — and never exercised the claim it names: that the ability actually appears in `legal_actions` from the graveyard. Moved the `activation_zone` assertion below the runtime one, with a comment explaining the ordering so it is not tidied back up. Verified by experiment: with the production line reverted the row now fails at `offers_activation` (`legal_actions returned [PassPriority]`), proving the ability was wrongly withheld in the graveyard — the other half of the reported bug. The suite now discriminates the runtime seam in both directions rather than only the battlefield negative. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 4 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
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 (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughActivation-zone derivation now recognizes self-sacrifice costs and traverses complete own-resolution ability trees for non-battlefield self-moves. Parser, synthesis, and integration tests cover precedence, branch handling, action availability, Aura behavior, and Craft activation. ChangesActivation-zone behavior
Estimated code review effort: 4 (Complex) | ~45 minutes Mergeability Score: ⚪ Minimal · up to This localized parser correction is supported by passing tests and targeted census/runtime validation, with no actionable merge-blocking risk remaining after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant IntegrationTests
participant OracleParser
participant ActionEngine
participant GameState
IntegrationTests->>OracleParser: parse self-return ability
OracleParser-->>IntegrationTests: assign Graveyard activation zone
IntegrationTests->>ActionEngine: submit activation from Graveyard
ActionEngine->>GameState: resolve Graveyard to Hand change
GameState-->>IntegrationTests: return updated game state
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
crates/engine/src/parser/oracle.rs (1)
6805-6822: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winTraverse all ability branches when deriving the activation zone.
activation_zone_from_self_effectonly followssub_ability. A self-ChangeZoneinelse_abilityormode_abilitiesis therefore ignored. Extend the traversal acrosssub_ability,else_ability, andmode_abilities, matching the existing ability visitors.🤖 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.rs` around lines 6805 - 6822, The activation-zone traversal in activation_zone_from_self_effect currently follows only sub_ability; extend it to visit else_ability and every mode_abilities branch as well. Reuse the existing ability-visitor traversal behavior and preserve the current written-order handling while deriving zones from self-ChangeZone effects.Source: Learnings
🤖 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.rs`:
- Around line 6909-6923: Update activation_zone_from_self_cost to recognize
Sacrifice costs whose target is TargetFilter::SelfRef and return
Some(Zone::Battlefield). Preserve the existing derivation precedence so
self-sacrifice costs select the battlefield activation zone instead of allowing
effect-side derivation to select the graveyard.
---
Nitpick comments:
In `@crates/engine/src/parser/oracle.rs`:
- Around line 6805-6822: The activation-zone traversal in
activation_zone_from_self_effect currently follows only sub_ability; extend it
to visit else_ability and every mode_abilities branch as well. Reuse the
existing ability-visitor traversal behavior and preserve the current
written-order handling while deriving zones from self-ChangeZone effects.
🪄 Autofix
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: ae6c7ec8-3474-4917-9c9a-691ffed79841
📒 Files selected for processing (5)
crates/engine/src/database/synthesis.rscrates/engine/src/parser/oracle.rscrates/engine/src/parser/oracle_tests.rscrates/engine/tests/integration/graveyard_to_hand_activation_zone.rscrates/engine/tests/integration/main.rs
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
|
Maintainer fixup pushed for current head ff7a54a: self-sacrifice cost authority now derives Battlefield; effect-side source-zone discovery uses the canonical own-resolution traversal, including sub/otherwise/modal branches; focused parser and runtime regressions added. Formatter and repository parser gates passed locally. Holding for required CI, exact-head parse-diff, and refreshed automated feedback before approval/enqueue. |
|
Current-head recheck: required CI is now failing, so this PR remains held. The maintainer self-sacrifice inference adds serialized |
|
Maintainer follow-up pushed for current head a7494d1. It narrows the self-sacrifice source-zone authority so ordinary battlefield activations retain the default |
|
Current-head recheck: all Rust test shards, card-data, WASM/Tauri, frontend, security, exact-head parse-diff, and refreshed CodeRabbit feedback are clean. This PR remains held because the required Rust lint job fails at |
|
Maintainer follow-up pushed for current head 5b4cd79d1900eae0924945e82109036316d030a8: the only change replaces the eagerly evaluated activation-zone |
|
Correction: the exact current head is |
matthewevans
left a comment
There was a problem hiding this comment.
Approved: current-head activation-zone derivation preserves the default battlefield representation while honoring self-sacrifice cost authority; required CI and exact-head parse evidence are green.
…ature (phase-rs#7330) `activation_zone` appeared zero times in `game/coverage.rs`, so an activated ability's parse-diff signature never rendered the zone it functions from. `can_activate_ability_now` gates legality on that field and the candidate enumerators key their hand, graveyard and library loops off it, so a change to it moves cards between "offered" and "not offered" — and the PR gate could not see any of it. Surfaced on phase-rs#7316, which moved 55 cards from `activation_zone: null` to `Graveyard`, withdrawing a battlefield offer and unlocking a graveyard one on each. Its parse-diff sticky reported `No card-parse changes detected.` That is the inverse of phase-rs#5507's failure mode and worse: there, removals with no compensating addition made a correct fix look like a regression — visibly wrong, so a reviewer investigates. Here a rules-behavior change across 55 cards was indistinguishable from a no-op. Fourth instance of the class, after phase-rs#5492, phase-rs#5495, phase-rs#5507 and phase-rs#5673 — and the first in `ability_details` rather than `effect_details`, which is where phase-rs#5507's exhaustive-destructuring recommendation was applied. The ability shell renders `AbilityDefinition`'s own fields and picks them by hand from a struct with over thirty of them. The key is `activates from`, not `from`: `effect_details` already emits `from` for a `ChangeZone` origin and `trigger_details` for a trigger origin, and `build_ability_item` silently drops duplicate keys — reusing `from` would hide this on precisely the abilities it exists to watch. Emitted unconditionally; `None` is the CR 113.6 battlefield default and emits nothing, so the ~12k abilities that default to the battlefield keep byte-identical signatures. Measured, base vs head on the same corpus: 79 clusters, every one `ability | field | activates from | ∅ → <zone>`, 986 card rows (651 hand, 333 graveyard, 1 exile, 1 command zone). Zero SupportFlip, zero added, zero removed, zero oracle_changed. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
🤖 AI text below 🤖
Summary
CR 113.6m restricts an activated ability to the zone its cost or effect moves the source out of. The rule says nothing about where the card goes.
activation_zone_from_self_effectadditionally requireddestination: Zone::Battlefield, so a self-move to any other destination derived no zone at all and fell back to the CR 113.6 battlefield default.Reported from a real game: two Bestial Bloodline Auras on the battlefield offered
{4}{G}: Return this card from your graveyard to your hand.Both halves were wrong — the ability was offered where it does not function, and withheld in the graveyard where it does. The 124Graveyard → Battlefieldcards (Reassembling Skeleton class, CR 113.6m's own printed example) worked only because their destination happened to satisfy the extra constraint.The fix deletes that pattern field. It is a constraint removal, not a new case: the function already generalised correctly along the origin axis — the doc-comment and the
origin != Zone::Battlefieldguard show the intent — but its author pinned the destination to the one value the motivating card used (#425, Talon Gates of Madara,Hand → Battlefield).This is the engine defect scoped out of #7296, which named it in prose and deliberately built its fixtures on Cooped Up and Cage of Hands so they stay green once it is fixed. They do.
55 abilities across 55 cards gain
activation_zone: Graveyard; no other ability changes in any direction, in either direction of the census. Two constraints were verified rather than assumed:synthesize_craft(database/synthesis.rs:655) builds itsAbilityDefinitiondirectly and never reachesparse_activated_ability_ir. The proof is structural, not incidental: those 21 sit atactivation_zone: Nonedespite carryingdestination: Battlefield, which the pre-fix pattern would have accepted — so this derivation demonstrably never ran on them. Independently, theirExile{zone: Battlefield, filter: SelfRef}cost component makesactivation_zone_from_self_costyieldBattlefieldfirst anyway, and CR 702.167a + CR 113.6m'sunlessclause say the battlefield is correct.Hand. It is the only parsed ability where the cost-side and effect-side derivations disagree (Discard this card→Hand;Shuffle this card into your library from your graveyard→Graveyard). The.or_else()order already resolved it, but only latently: the destination constraint (Library ≠ Battlefield) kept the effect side silent, so removing it makes that precedence load-bearing for the first time. CR 113.6j and CR 118.3 make a graveyard activation unpayable rather than merely suboptimal, and CR 113.6m'sunlessclause makes the effect side inapplicable by rule. A new test pins the ordering and asserts both derivations actually fire, so a future refactor cannot "simplify" it away.Three parts of CR 113.6m are deliberately not implemented because each governs a measurably empty class at this corpus vintage; each has its extension point named in the doc-comment: the
unlessclause's effect half (0 cards), its Aura half (0 of the 7 Auras in the class qualify — the exception is satisfiable by a cost, effect or trigger condition, not only a trigger condition), and sentence 2's delayed-trigger case (0 operative cards; the 58 abilities carrying that shape are synthesized unearth, whose delayed move isBattlefield → Exile, i.e. the CR 113.6 default).Files changed
crates/engine/src/parser/oracle.rs— the fix: one deleted pattern field inactivation_zone_from_self_effect. Its doc-comment rewritten (the rule quantifies over origin only; both destinations are live in the corpus; what theorigin != Battlefieldguard is really for and that its class is currently empty; thesub_abilityrecursion is kind-agnostic, which Lochmere Serpent depends on). The.or_else()chain's comment replaced with the three-authority statement CR 113.6b ≻ CR 113.6j ≻ CR 113.6m, naming Kogla and Yidaro as the discriminating card.crates/engine/src/parser/oracle_tests.rs— seven new rows plus two repairs. Adds theactivation_zoneassertion that was missing fromparses_activate_only_timing_and_only_if_condition, which had parsed Gutterbones since day one while checking onlyactivation_restrictions— that gap is why this survived. Repairsbattlefield_self_changezone_leaves_activation_zone_unset, whose subject lowers toEffect::Bounceand so never reached theChangeZonearm it was meant to control; it now carries an explicit variant reach-guard.crates/engine/src/database/synthesis.rs— two assertions on the existing Craft synthesis test pinningactivation_zone == Noneand the battlefield-zoned self-exile cost.crates/engine/tests/integration/graveyard_to_hand_activation_zone.rs(new) — five runtime rows: the battlefield negative on a plain creature, the reported Aura case (attached, with CR 704.5m survival asserted rather than assumed), the graveyard positive resolving to hand, the Craft canary, and an over-restriction canary.crates/engine/tests/integration/main.rs— onemodline.Track
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: /engine-implementer
Plan → review → revise, four rounds to a clean plan review; then implement → committed checkpoint →
/review-impl→ findings addressed with code → final/review-impl. Each step ran in a fresh agent context; the author never reviewed its own work. Round 1 of plan review returned 7 blockers, round 2 returned 3, round 3 returned 2, round 4 clean. Two of those rounds found defects in test fixtures that would have passed green while proving nothing — a designated primary negative whose card carried two activation restrictions, so its ability was absent fromlegal_actionswith or without the fix; and a byte-identity gate on a JSON artifact that is not deterministic (CoverageSummary.cardsiterates aHashMap; three runs at the same SHA gave three digests), which would have stopped a correct run.CR references
CR 113.6,CR 113.6b,CR 113.6j,CR 113.6m,CR 118.3,CR 207.2c,CR 602.1,CR 603.7,CR 608.2k,CR 702.57a,CR 702.84,CR 702.167a,CR 704.5m— each grepped fromdocs/MagicCompRules.txtbefore being written. (CR 702.84is unearth; an earlier revision of this branch cited702.83, which is exalted. Caught by review, corrected in96daf61.)Verification
Required checks ran clean, or the exact CI-owned alternative is stated below.
Gate A output below is for the current committed head.
Final review-impl below is clean for the current committed head.
Both anchors cite existing analogous code at the same seam.
cargo test -p phase-engine— 18911 + 21 + 9 + 4857 passed, 0 failed, 15 ignoredcargo clippy-strict— cleancargo fmt --all— clean./scripts/check-parser-combinators.sh— Gate G PASS, Gate A PASSpre-commit — Gate P PASS (PreLowered ratchet: no producer count increased)
MTGJSON_SKIP_REFRESH=1 ./scripts/gen-card-data.shthen a fullactivation_zonecensus diffed against a pristine baseline at the merge base — exactly 55 rows change, allNone → GraveyardonGraveyard → Hand; 0 other abilities move in any direction; 12,927 activated abilities both sides; corpus totalsGraveyard 279 → 334,Hand 656 → 656,Exile 1 → 1,Command 1 → 1. Measured against MTGJSON5.3.0+20260810; CI parses its own, newer corpus, so new printings may add rows — any change to a row that existed at that vintage would not be this change.Two-way control, run twice (once per review round): with
destination: Zone::Battlefield,restored, the parser rows fail onactivation_zone, and the runtime rows fail in both directions —sibling_battlefield_pump_ability_still_offeredat the battlefield negative (the reported bug, reproduced throughlegal_actions), andbestial_bloodline_activatable_from_graveyard_returns_to_handat the graveyard positive withlegal_actions returned [PassPriority]. Every failure is a value mismatch, not a missing fixture; each runtime negative asserts its restrictions are empty and its cost affordable, so the restriction gate cannot be what makes it pass.Tilt is not installed in this environment;
scripts/tilt-wait.shwould return3(cannot answer), never a build result, so the isolated-direct commands above were used.cargo nextestis likewise unavailable;cargo testwas used.Gate A
Gate A PASS head=4baa94e7508ce31588a039a84c422e446e9c3ea2 base=479ad396d46338d6cd571da8b6b7fd8f375a307e
Anchored on
crates/engine/src/parser/oracle.rs:6746—activation_zone_from_self_cost, the cost-side sibling of the changed function and the other half of the same.or_else()authority chain; unchanged here, and it keeps priority.crates/engine/tests/integration/talon_gates_from_hand_activation.rs— the end-to-end pattern for the same derivation in theHand → Battlefielddirection (issue Talon Gates of Madara bug — The last activated ability of [[Talon Gates of Madara]] can't be activated even with enough… #425), which the new integration file mirrors for the graveyard direction.Final review-impl
Final review-impl PASS head=4baa94e7508ce31588a039a84c422e446e9c3ea2
Claimed parse impact
55 cards, all in one direction (
activation_zoneabsent →Graveyard), all on aChangeZone{origin: Graveyard, destination: Hand, target: SelfRef}ability. The parse-diff comment on this PR will be empty — see Validation Failures.A-Earthquake Dragon, Abzan Devotee, Altar of the Wretched, Bestial Bloodline, Brackish Trudge, Chamber Sentry, Clattering Augur, Clay Revenant, Cleaving Reaper, Convenient Target, Crown of Skemfar, Deathless Ancient, Deathless Behemoth, Deathless Pilot, Durable Coilbug, Dutiful Griffin, Earthquake Dragon, Eldrazi Ravager, Eternal Dragon, Evershrike's Gift, Firewing Phoenix, Gangrenous Goliath, Gilded Assault Cart, Gollum the Abandoned, Gollum, Patient Plotter, Grim Reminder, Gutterbones, Hammer of Bogardan, Jarad, Golgari Lich Lord, Jungle Creeper, Kraul Swarm, Lochmere Serpent, Magma Phoenix, Merchant of Many Hats, Metalwork Colossus, Multani, Yavimaya's Avatar, Phantasmagorian, Pilgrim of the Ages, Project Deathlok Soldier, Repeating Barrage, Salvage Titan, Sanitarium Skeleton, Shard Phoenix, Skarrgan Firebird, Summon the School, Summoned Dromedary, Talons of Wildwood, The Sound of Drums, Tymaret, the Murder King, Undead Gladiator, Unshakable Tail, Vineweft, Vivien's Jaguar, Whiteout, World Breaker
50 are permanents (7 of them Auras, which is why this surfaced as an Aura bug) and 5 are instants or sorceries. For the permanents the fix both withdraws the wrong battlefield offer and unlocks the correct graveyard one; for the 5 the ability was simply dead, since an instant is never on the battlefield.
Two of the 55 are card-level
supported: falsefor pre-existing, unrelated reasons — A-Earthquake Dragon's cost-reduction static and Grim Reminder's spell body. TheChangeZoneitem issupported: trueon all 55, and no card'ssupportedflips in either direction.Scope Expansion
None.
Validation Failures
The CI parse-diff cannot see this change, and its silence is not evidence.
ability_details(game/coverage.rs:3759) emitskind,duration,repeat_for,targeting,targets,conditional,timingandmodal— but notactivation_zone. None of the 158 distinct detail keys in the shipped export contains the substringactivat, andcoverage-data.jsonis byte-comparable across this change. Socoverage-parse-diffwill report zero clusters for a change that moves 55 cards, and every claim above rests on the card-data census and the runtime tests instead.That blindness is a pre-existing gap in the instrument, not a property of this change: any change to a rules-load-bearing field that decides whether an ability is offered at all is currently invisible to the PR gate. The fix is one unconditional key in
ability_details("activates from"— verified free;"from"is already taken atcoverage.rs:2743,:2803and:3832, andbuild_ability_itemsilently drops duplicate keys at:4832). It is deliberately not in this PR: it is a 937-ability additive schema migration with a different risk profile, and bundling it would collapse its rows and this fix's rows into the same parse-diff clusters, making both unreadable. Happy to open it as a follow-up if maintainers want it, in which case this PR's own rows become legible for the first time.cargo ai-gatewas not run —crates/phase-ai/is outside this change's scope. Measured instead: none of the 55 card names appears incrates/phase-ai/duel_decks/{standard,pioneer,modern,legacy,pauper}orcrates/phase-ai/fixtures/, so no baseline is expected to move.cargo engine-inventoryran clean but had no committed baseline to diff against (the artifact is gitignored and absent in a fresh worktree). The change adds, removes and renames no enum or variant and touches no file undertypes/, so an inventory delta is structurally impossible.An attempt to verify the fix against the reporter's saved game file was made and abandoned as unsound, rather than reported as a pass: the ability text occurs 60 times across that save (zone lists, card definitions, five turn checkpoints), so editing one occurrence proves nothing about what the engine reads. Runtime evidence rests on the integration tests. Note also that a saved game legitimately keeps its serialized abilities, so an in-progress save carries the old behaviour forward; only new games pick up the corrected parse.
CI Failures
None.
Summary by CodeRabbit
Bug Fixes
Tests