fix(parser): scope each-player phase-trigger anaphors to the phase player (Citadel of Pain #6508)#6564
Conversation
…ayer (Citadel of Pain phase-rs#6508) "At the beginning of each player's end step, ~ deals X damage to that player, where X is the number of untapped lands they control." counted the SOURCE's controller's untapped lands instead of the phase player's, so on an opponent's end step Citadel dealt the controller's count (often 0) rather than the opponent's. Root cause is a parse-time anaphor mis-binding. The trigger already binds "that player" anaphors to the phase's active player (ControllerRef::ScopedPlayer) via relative_player_scope_for_condition — which is why the DealDamage recipient parsed correctly. But the "where X is ... they control" count is stripped as a raw string and interpreted at assembly time through the context-free parse_cda_quantity, so relative_player_scope was None and "they control" fell to the legacy unwrap_or(ControllerRef::You). The sibling for-each interpreter in the same function already defaults this anaphor to ScopedPlayer; the where-X CDA arm simply never carried the context. Part A: carry the ScopedPlayer anaphor context into the CDA-quantity delegate via the existing for_each_anaphor_context + parse_cda_quantity_with_context. ScopedPlayer degrades to the source's controller at runtime when no scope is stamped, so spell where-X reads are unchanged; only each-player/each-opponent phase triggers read the phase player. Part B: lower_trigger_ir had TargetPlayer and SourceChosenPlayer rewrite passes but no ScopedPlayer branch, so possessive quantities (TargetZoneCardCount{Hand}, LifeTotal{Target}) in these triggers stayed target-marker refs that resolve to 0 at runtime with no player target — Iron Maiden always dealt 0, Rackling always dealt max, Havoc Festival lost 0 life. Add the missing ScopedPlayer branch, reusing the identical rewrite_event_player_quantity_refs_to_scoped the TargetPlayer branch already calls (rewrites only Target/TargetZoneCardCount, never Controller, so mixed-anaphor Dark Suspicions keeps its -HandSize{Controller} operand). Behavior-fixed: Citadel of Pain, Iron Maiden, Viseling, Rackling, Storm World, Wheel of Torture, Dark Suspicions, Dreamborn Muse, Price of Knowledge, Havoc Festival. Parser-only; no runtime files change. Closes phase-rs#6508 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThe parser now supplies scoped-player context for where-X quantities, and trigger lowering rewrites player references for each-player phases. Regression coverage validates controller, hand, life, and damage bindings through parser and integration tests. ChangesScoped player binding
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Parse changes introduced by this PR · 13 card(s), 12 signature(s) (baseline: main
|
|
Part B is clean and at the right seam — Part A is not. Reviewed at head 📊 Parse-diff verdict: claimed 14, measured 13Sticky comment All 10 claimed behavior-fixed cards check out — each Oracle text verified against Scryfall: Citadel of Pain, Iron Maiden, Viseling, Rackling, Wheel of Torture, Storm World, Dark Suspicions, Dreamborn Muse, Price of Knowledge, Havoc Festival. Dark Suspicions correctly retains its Unexplained #1 — Inscription of Abundance is claimed but absent from the measurement. Its clause is an aggregate ("where X is the greatest power among creatures they control"), not an Unexplained #2 — the 3 "label-only" cards are target-player anaphors (Scryfall-verified):
The correct referent for all three is 🔴 Blockers[HIGH]
The sibling interpreter already does this correctly: Suggested fix: add [MED] The "runtime-identical" justification cites the wrong resolver.
Confidence: high that the comment is wrong about its own code path; medium that the divergence is reachable ( [MED] Fixture path-divergence. All 4 runtime tests ( [MED] Claimed parse impact (14) ≠ measured (13). Reconcile Inscription of Abundance. ✅ Clean — verified
🟡 Non-blocking
Recommendationrequest-changes, contributor round-trip — not a maintainer fixup. Threading
Part B is clean — keep it as is. Your CI red is not your fault and cannot be fixed by rebasing, because main's tip is the broken commit: |
…mp (#6568) `release: v0.35.2` (21a53d5) bumped `client/src-tauri/Cargo.toml` to 0.35.2 via cargo-release's `pre-release-replacements`, but `client/src-tauri/` is a separate cargo workspace with its own lockfile that cargo-release does not manage. The lock still recorded `phase-tauri 0.35.1`, so the `tauri-check` CI job's `cargo check --locked --manifest-path client/src-tauri/Cargo.toml` refused to reconcile the mismatch and exited 101. That reds the required `Rust (fmt, clippy, test, coverage-gate)` aggregator (which `needs: [... tauri-check]`) on every open PR whose merge ref includes the release commit, blocking the merge queue repo-wide. Evidence: PR #6561 tauri-check PASSED at 20:43:00Z; the release commit landed at 20:49:54Z; PRs #6564 (20:56:21Z) and #6563 (21:15:11Z) both FAILED with the identical --locked error. None of the three touched any Cargo manifest. Follow-up (not in this change): cargo-release should keep the nested lockfile in sync so the next release does not re-break it. Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Summary
Fixes Citadel of Pain (#6508) and the each-player/each-opponent phase-trigger anaphor class: "At the beginning of each player's end step, this enchantment deals X damage to that player, where X is the number of untapped lands they control." On an opponent's end step Citadel counted the controller's untapped lands instead of the phase player's — so opponents visibly never took the right amount (frequently 0), while the controller took its own count on its own end step.
Root cause
Parse-time anaphor mis-binding. The each-player phase trigger correctly binds "that player" anaphors to the phase's active player (
ControllerRef::ScopedPlayer) viarelative_player_scope_for_condition— which is why the DealDamage recipient parsed correctly. But thewhere X is … they controlcount is stripped as a raw string and interpreted later, at assembly time, through the context-freeparse_cda_quantity— sorelative_player_scopewasNoneand "they control" fell to the legacyunwrap_or(ControllerRef::You), binding the count to the source's controller. The siblingfor-eachinterpreter in the same function already defaults this anaphor toScopedPlayer; the where-X CDA arm simply never carried the context.A second, live gap in the same trigger family:
lower_trigger_irhad rewrite passes forTargetPlayerandSourceChosenPlayerscopes but noScopedPlayerbranch, so possessive quantities in these triggers (TargetZoneCardCount{Hand},LifeTotal{Target}) stayed target-marker refs that resolve to 0 at runtime when the trigger has no player target — Iron Maiden always dealt 0, Rackling always dealt max, Havoc Festival lost 0 life, etc.Fix (parser-only, +73/-2 across 3 files)
oracle_effect/lower.rs,parse_where_x_quantity_expression): carry theScopedPlayeranaphor context into the CDA-quantity delegate via the existingfor_each_anaphor_context+parse_cda_quantity_with_context, exactly as the sibling for-each interpreter does.ScopedPlayerdegrades to the source's controller at runtime when no scope is stamped, so spell where-X reads are unchanged; only each-player/each-opponent phase triggers now read the phase player.oracle_trigger.rs,lower_trigger_ir): add the missingSome(ControllerRef::ScopedPlayer)rewrite branch, reusing the identicalrewrite_event_player_quantity_refs_to_scopedtheTargetPlayerbranch already calls — converting the target-marker possessives toHandSize{ScopedPlayer}/LifeTotal{ScopedPlayer}. It rewrites onlyPlayerScope::TargetandTargetZoneCardCount, neverController, so a mixed-anaphor card like Dark Suspicions keeps its-HandSize{Controller}operand intact.for_each_anaphor_contextwidened topub(crate).Behavior-fixed cards: Citadel of Pain (#6508), Iron Maiden, Viseling, Rackling, Storm World, Wheel of Torture, Dark Suspicions, Dreamborn Muse, Price of Knowledge, Havoc Festival. (Noetic Scales shares the family but embeds its count in a filter's
PtComparison, which the quantity visitor doesn't descend into — documented residual, separate follow-up.)Files changed
crates/engine/src/parser/oracle_effect/lower.rs— Part A (+ where-X unit test)crates/engine/src/parser/oracle_trigger.rs— Part Bcrates/engine/src/parser/oracle_quantity.rs—pub(crate)visibilitycrates/engine/src/parser/oracle_trigger_tests.rs— 4 parser SHAPE tests (Citadel, Iron Maiden, Dark Suspicions multi-authority, Havoc Festival, + you-control negative w/ reach-guard)crates/engine/tests/integration/citadel_of_pain_each_player_end_step_6508.rs— 4 runtime testscrates/engine/tests/integration/main.rs— mod lineCR references
CR 513.1(end step),CR 603.2b(phase trigger fires),CR 102.1(active player = "that player"),CR 109.5(why "you control" stays You),CR 608.2c(anaphor binding),CR 503.1a(Iron Maiden upkeep test),CR 110.5(tapped/untapped status).Implementation method (required)
Method: /engine-implementer
Track
Developer
LLM
Model: claude-opus-4-8[1m]
Thinking: high
Verification
Required checks ran clean.
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 engine --lib— 17596 passed, 0 failed, 6 ignored (baseline 17590 + 6 new parser tests)cargo test -p engine --test integration— 3873 passed, 0 failed, 2 ignored (baseline 3869 + 4 new)cargo fmt --all -- --check— clean./scripts/gen-card-data.sh— regenerated; parse-diff audit of the family confirms every expected card flipped to the phase-player form and nothing else changed: Citadel amountObjectCount{controller: ScopedPlayer}; Iron Maiden/ViselingOffset{HandSize{ScopedPlayer}, −4}; Rackling/Storm World/Wheel of TortureClampMin{N − HandSize{ScopedPlayer}}; Dreamborn Muse/Price of KnowledgeHandSize{ScopedPlayer}; Havoc FestivalDivideRounded{LifeTotal{ScopedPlayer}, 2}; Dark SuspicionsSum[HandSize{ScopedPlayer}, −HandSize{Controller}](controller operand intact); The Rack staysSourceChosenPlayer, Copper Tablet staysFixed, Ancient Runes unchanged.RED/GREEN: T1 (P0=1/P1=3 untapped → asserts P1 −3; pre-fix −1), T2 (P1 lands tapped → 0; pre-fix −2), T4 (Iron Maiden hand 7−4 → −3; pre-fix
TargetZoneCardCountresolves 0 → 0), plus all shape tests fail on revert. The you-control negative carries a positive reach-guard.Gate A
Gate A PASS head=03c3aa6ac6c8ca090fbc05ecfefb047298db2600 base=6ae8737cdab0fa1ed291cad0f0808473a90f4cf8
Anchored on
crates/engine/src/parser/oracle_quantity.rs:3337—for_each_anaphor_context+parse_cda_quantity_with_context(oracle_quantity.rs:781): the sibling for-each interpreter that already defaults the third-person "they control" anaphor toScopedPlayer; Part A adopts the same helper at the where-X CDA arm.crates/engine/src/parser/oracle_trigger.rs:1629— the existingTargetPlayer/SourceChosenPlayerrewrite branches inlower_trigger_ircallingrewrite_event_player_quantity_refs_to_scoped/rewrite_player_quantity_refs_to_source_chosen; Part B adds the missingScopedPlayerrung to that same ladder (oracle_trigger.rs:1648) reusing the same rewrite fn.Final review-impl
Final review-impl PASS head=03c3aa6ac6c8ca090fbc05ecfefb047298db2600
Claimed parse impact
Validation Failures
None.
CI Failures
None.
Tier: Frontier
Summary by CodeRabbit
Bug Fixes
where-Xexpressions so they correctly refer to the player whose phase is being processed.Tests