fix(engine): parse and resolve Power Leak's dynamic damage prevention#5742
Conversation
…fixed, note deferred B/C consolidation follow-up
There was a problem hiding this comment.
Code Review
This pull request implements CR 608.2 by snapshotting and replaying the resolving ability's trigger-event context across interactive pauses, ensuring correct resolution of filters like TriggeringPlayer. It also introduces a parser optimization to fold the 'deal N damage, then prevent X' idiom into a single computed-amount node, generalizes the 'amount of mana paid this way' parser, and expands Aura phase trigger scoping to all enchant types. Feedback recommends correcting a CR annotation format in choose_from_zone.rs to comply with Rule R6, and updating ResolvingTriggerContext::capture to check current_trigger_events for complete correctness under batched triggers.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Forced field-threading for the new plural-event list (CR 603.2c); | ||
| // mechanism-B behavior is otherwise unchanged. |
There was a problem hiding this comment.
The CR annotation in this comment does not follow the mandatory CR <number>: <description> format specified in the repository style guide (Rule R6). Please reformat it to ensure strict adherence and consistency across the codebase.
| // Forced field-threading for the new plural-event list (CR 603.2c); | |
| // mechanism-B behavior is otherwise unchanged. | |
| // CR 603.2c: Forced field-threading for the new plural-event list; | |
| // mechanism-B behavior is otherwise unchanged. |
References
- Every rules-touching line of engine code must carry a comment of the form
CR <number>: <description>(regexCR \d{3}(\.\d+[a-z]?)?). (link)
| (state.current_trigger_event.is_some() | ||
| || state.current_trigger_match_count.is_some() | ||
| || state.die_result_this_resolution.is_some()) |
There was a problem hiding this comment.
For complete correctness and defensive programming under batched triggers, ResolvingTriggerContext::capture should also check if current_trigger_events is non-empty. This ensures that a live trigger context is captured even if the singular current_trigger_event is not set.
(state.current_trigger_event.is_some()
|| !state.current_trigger_events.is_empty()
|| state.current_trigger_match_count.is_some()
|| state.die_result_this_resolution.is_some())References
- Verify code functionality, handle edge cases, and ensure appropriate checks exist before accessing or ignoring state.
Parse changes introduced by this PR · 9 card(s), 5 signature(s) (baseline: main
|
116412d to
f0af1d5
Compare
matthewevans
left a comment
There was a problem hiding this comment.
Blocked — the new behavior has no production-pipeline regression test.
🔴 Blocker
crates/engine/src/game/effects/pay.rs:1861tests the context restoration by constructing aResolvedAbility, callingresolve_ability_chain, and manually clearing the trigger context at lines 159–166. That reaches the new drain code, but it bypasses both the production parser andstack::resolve_top—the two boundaries this PR changes and relies on.- Power Leak’s official Oracle text is: “At the beginning of the upkeep of enchanted enchantment's controller, that player may pay any amount of mana. This Aura deals 2 damage to that player. Prevent X of that damage, where X is the amount of mana that player paid this way.” The current parser tests validate AST shape, and the helper test validates a synthesized chain, but neither proves that the real parsed trigger is scoped to the enchanted controller, survives the stack/pause boundary, and applies
max(2 − X, 0)to that player.
✅ Clean
- The
PendingContinuationsnapshot/restore is at the shared continuation seam, and the parser diff’s Power Leak / Errant Minion / Liege scope is explained by the reviewed Oracle forms.
Recommendation: add a registered integration test that drives the real parsed Power Leak through an upkeep trigger, accepts a nonzero mana payment, and asserts the enchanted controller—not the Aura controller—takes exactly 2 − X damage. Include a decline or X ≥ 2 sibling case if the fixture makes it cheap; then re-request review.
|
Added Three cases:
Live-reverted the The existing Re-requesting review. |
Power Leak ("that player may pay any amount of mana. This Aura deals 2
damage to that player. Prevent X of that damage, where X is the
amount of mana that player paid this way") had three distinct parser
bugs, none of which matched the misparse-backlog's original
"replacement effect" framing for this card:
- The where-X recognizer for "the amount of mana paid this way" only
matched one exact phrasing ("the total amount..."), not Power Leak's
or Errant Minion's possessive-pronoun variant, or Liege of the
Hollows' silently-broken "they paid this way" variant (which had no
parse warning at all, since it fell through into a literal-string
Variable that could never bind).
- The card's "This Aura deals 2 damage... Prevent X of that damage"
was modeled as two separate sequential effects. Since prevention
effects must exist before the damage event they prevent (CR 615.4),
and PreventDamage's "prevent the next N" shield is a depleting
shield that leaks unconsumed capacity onto later, unrelated damage
this turn, this shape was more than a simple wrong-target bug — it
needed collapsing into a single DealDamage with a computed
max(2-X, 0) amount, the same "later text modifies an earlier
effect" pattern the engine already uses for Destroy's
cant_regenerate flag (CR 608.2c).
- The "at the beginning of the upkeep of enchanted X's controller"
trigger-scope recognizer only matched "creature"/"permanent" nouns,
so Power Leak's trigger fired on every player's upkeep instead of
just the enchanted permanent's controller's. Fixed by delegating to
the existing shared Enchant-noun combinator, incidentally fixing 6
sibling cards (Curse Artifact, Cursed Land, Erosion, Feedback,
Power Taint, Warp Artifact).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
…on resume Discovered while making Power Leak's parser fixes actually work at runtime: TargetFilter::TriggeringPlayer (and the other 11 is_pure_event_context_filter variants, plus PlayerFilter/QuantityRef readers of the same fields) resolves to the wrong player whenever ability resolution pauses mid-chain on a player choice and then resumes via PendingContinuation. stack::resolve_top unconditionally clears current_trigger_event and its siblings once the stack entry that started a resolution leaves the stack (CR 603.7c cleanup), regardless of whether that resolution's ability chain actually finished or merely paused into a continuation — so by the time a paused chain resumes, the context needed to resolve "that player" is already gone, and resolution falls back to the ability's own controller instead. Confirmed via a real A/B test: Warp Artifact (same trigger shape, no payment pause) correctly damages the enchanted permanent's controller; Power Leak (same shape, but with a "may pay any amount of mana" pause first) incorrectly damaged the Aura's own controller instead. Fixed by extending the existing ResolvingTriggerContext type (already used by one other pause mechanism) with its one missing field, giving PendingContinuation a single snapshotted field of that type captured at construction time, and wiring drain_pending_continuation to push that snapshot as the live context before resuming a paused chain and restore it afterward — mirroring the save/restore pattern already shipped for WaitingFor::UnlessPayment's identical pause/resume shape. Two other, narrower pre-existing mechanisms solve the same conceptual problem for two other pause types and were deliberately left untouched (documented at ResolvingTriggerContext's definition); consolidating all three is a real, identified follow-up, deferred here to keep this fix's blast radius on the reported bug rather than bundling a deletion of currently-working code into a user-facing fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
…resolution The prior commit's PendingContinuation.trigger_context fix (restoring the enclosing trigger's event context across a paused/resumed ability chain) had a real side effect on CR 603.12 reflexive triggers: "may [cost]. When you do, [effect using an EventContextAmount 'that many' bound]" abilities now saw the enclosing trigger's own event-amount (e.g. an attack trigger's attacker count) win over the reflexive's own resolution-local count (e.g. the actual number of Treasures sacrificed), because that enclosing event is correctly restored before the reflexive's target slots get built. Confirmed via CI regression: Swashbuckler Extraordinaire's "Whenever you attack, you may sacrifice one or more Treasures. When you do, up to that many target creatures gain double strike" started offering only 1 target slot (the attacker count) instead of 2 (the sacrifice count). Fixed by suppressing only the two "read the enclosing trigger's own event" tiers of the EventContextAmount cascade for the entire dynamic extent of a reflexive's target-slot construction and count-freezing (CR 603.12: a reflexive's own "that many" is resolution-local to the ability that created it, never the enclosing trigger), via a thread-local scope mirroring an existing analogous mechanism in the same file. Every other tier of the cascade is untouched, and the scope is set at exactly one call site, so no other consumer of this shared resolution cascade is affected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
Addresses review feedback on this PR: the existing runtime tests for the TriggeringPlayer-across-pause fix (in game/effects/pay.rs) drove a hand-built ability chain through resolve_ability_chain directly, bypassing both the real Oracle parser and the real stack::resolve_top boundary — the two seams this PR's fixes actually depend on. Adds a new integration test driving Power Leak's real parsed Oracle text through a genuine upkeep trigger (real GameRunner phase advancement, real OptionalEffectChoice/PayAmountChoice/SubmitPayAmount round-trip), asserting the enchanted permanent's controller — never the Aura's own controller — takes exactly max(2-X, 0) damage, at three points: a nonzero payment, a full decline, and an overpayment that fully prevents the damage. The pre-existing pay.rs helper tests are left in place as defense-in-depth; this test is additive, closing the specific gap the review identified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
Co-authored-by: Rajah James <rajah.james@grantek.com>
Co-authored-by: Rajah James <rajah.james@grantek.com>
Co-authored-by: Rajah James <rajah.james@grantek.com>
9ee7d22 to
3391235
Compare
Keep the reflexive trigger-context isolation while restoring the EventContextAmount order: batched match count, then die result, then scalar trigger event.\n\nCo-authored-by: Rajah James <rajah.james@grantek.com>
matthewevans
left a comment
There was a problem hiding this comment.
Verdict: Approved
🔴 Blocker
- None.
🟡 Non-blocking
- Gemini's plural
current_trigger_eventscapture concern is addressed atcrates/engine/src/types/game_state.rs; its CR-format concern is not valid because compound CR citations are the repository convention.
✅ Clean
- The registered Power Leak integration test drives the real parsed upkeep trigger through
stack::resolve_top, the optional-payment pause, andSubmitPayAmount, including decline and full-prevention siblings. - The current parse-diff matches the claimed 9-card scope: seven Aura phase triggers, Power Leak/Errant Minion's computed damage, and Liege of the Hollows' X-token binding.
- The maintainer fix restores
EventContextAmountprecedence—batched match count, die result, then scalar trigger event—and the fresh CI run is green onc0fba2f.
Recommendation: approve and enqueue.
Summary
Power Leak ({1}{U}, Enchantment — Aura, old-school): "Enchant enchantment\nAt the beginning of the upkeep of enchanted enchantment's controller, that player may pay any amount of mana. This Aura deals 2 damage to that player. Prevent X of that damage, where X is the amount of mana that player paid this way."
This card was flagged under misparse-backlog category #11 ("replacement/instead effect mis-modeled"), but that framing doesn't match the card's Oracle text — there's no "would...instead" clause anywhere in it. Investigation found three distinct, real bugs, plus a separate runtime bug discovered while making the parser fixes actually work end-to-end:
Parser fixes:
DealDamagewith a computedmax(2-X, 0)amount — the same "later text modifies an earlier effect" pattern the engine already uses forDestroy'scant_regenerateflag (CR 608.2c).A separate runtime bug, discovered because the parser fixes alone didn't make Power Leak resolve correctly:
TargetFilter::TriggeringPlayerresolves to the wrong player whenever ability resolution pauses mid-chain on a player choice (e.g. Power Leak's own "may pay any amount of mana") and then resumes. Root cause:stack::resolve_topunconditionally clears the live trigger-event context once its stack entry leaves the stack, regardless of whether the ability chain actually finished or merely paused into a continuation — so by the time a paused chain resumes, the context needed to resolve "that player" is gone and resolution falls back to the ability's own controller instead. Confirmed via a real A/B test: Warp Artifact (same trigger shape, no payment pause) correctly damages the enchanted controller; Power Leak (same shape, but with a payment pause) incorrectly damaged the Aura's own controller instead.Fixed by extending an existing type (
ResolvingTriggerContext, already used by one other pause mechanism) with its one missing field, giving the paused-continuation carrier a single snapshotted field of that type, and wiring the continuation-resume path to push that snapshot as the live context before resuming and restore it afterward — mirroring a save/restore pattern already shipped for an analogous pause type. Two other, narrower pre-existing mechanisms solve the same conceptual problem for two other pause types and were deliberately left untouched (documented in place); consolidating all three is a real, identified follow-up, deferred here to keep this fix's blast radius on the reported bug.Test plan
PreventDamagefold to a computedDealDamageamount with no leftover node; Liege of the Hollows' token count binds to the shared X variable instead of a dead placeholder; the 5 existing Join Forces cards' "total amount" binding is unregressed; Warp Artifact/Cursed Land/Feedback's trigger scoping generalizes correctly.cargo test -p engine --lib: 16491 passed, 0 failed.cargo clippy -p engine --lib --tests -- -D warningsclean.cargo fmt --all.🤖 Generated with Claude Code
https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K