Skip to content

fix(parser): scope each-player phase-trigger anaphors to the phase player (Citadel of Pain #6508) - #6564

Open
jeffrey701 wants to merge 6 commits into
phase-rs:mainfrom
jeffrey701:fix/6508-each-player-phase-anaphor
Open

fix(parser): scope each-player phase-trigger anaphors to the phase player (Citadel of Pain #6508)#6564
jeffrey701 wants to merge 6 commits into
phase-rs:mainfrom
jeffrey701:fix/6508-each-player-phase-anaphor

Conversation

@jeffrey701

@jeffrey701 jeffrey701 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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) 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 later, 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), binding the count to the source's controller. 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.

A second, live gap in the same trigger family: lower_trigger_ir had rewrite passes for TargetPlayer and SourceChosenPlayer scopes but no ScopedPlayer branch, 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)

  • Part A (oracle_effect/lower.rs, parse_where_x_quantity_expression): carry the ScopedPlayer anaphor context into the CDA-quantity delegate via the existing for_each_anaphor_context + parse_cda_quantity_with_context, exactly as the sibling for-each interpreter does. 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 now read the phase player.
  • Part B (oracle_trigger.rs, lower_trigger_ir): add the missing Some(ControllerRef::ScopedPlayer) rewrite branch, reusing the identical rewrite_event_player_quantity_refs_to_scoped the TargetPlayer branch already calls — converting the target-marker possessives to HandSize{ScopedPlayer} / LifeTotal{ScopedPlayer}. It rewrites only PlayerScope::Target and TargetZoneCardCount, never Controller, so a mixed-anaphor card like Dark Suspicions keeps its -HandSize{Controller} operand intact.
  • Supporting: for_each_anaphor_context widened to pub(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 B
  • crates/engine/src/parser/oracle_quantity.rspub(crate) visibility
  • crates/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 tests
  • crates/engine/tests/integration/main.rs — mod line

CR 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 --lib17596 passed, 0 failed, 6 ignored (baseline 17590 + 6 new parser tests)

  • cargo test -p engine --test integration3873 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 amount ObjectCount{controller: ScopedPlayer}; Iron Maiden/Viseling Offset{HandSize{ScopedPlayer}, −4}; Rackling/Storm World/Wheel of Torture ClampMin{N − HandSize{ScopedPlayer}}; Dreamborn Muse/Price of Knowledge HandSize{ScopedPlayer}; Havoc Festival DivideRounded{LifeTotal{ScopedPlayer}, 2}; Dark Suspicions Sum[HandSize{ScopedPlayer}, −HandSize{Controller}] (controller operand intact); The Rack stays SourceChosenPlayer, Copper Tablet stays Fixed, 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 TargetZoneCardCount resolves 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:3337for_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 to ScopedPlayer; Part A adopts the same helper at the where-X CDA arm.
  • crates/engine/src/parser/oracle_trigger.rs:1629 — the existing TargetPlayer/SourceChosenPlayer rewrite branches in lower_trigger_ir calling rewrite_event_player_quantity_refs_to_scoped / rewrite_player_quantity_refs_to_source_chosen; Part B adds the missing ScopedPlayer rung 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

  • Citadel of Pain, Iron Maiden, Viseling, Rackling, Storm World, Wheel of Torture, Dark Suspicions, Dreamborn Muse, Price of Knowledge, Havoc Festival (behavior-fixed); Curious Herd, Jovial Evil, Pact of the Serpent, Inscription of Abundance (label-only ScopedPlayer normalization, runtime-identical for spells).

Validation Failures

None.

CI Failures

None.


Tier: Frontier

Summary by CodeRabbit

  • Bug Fixes

    • Corrected third-person anaphor and where-X player-scope binding in per-player triggers so phrases like “they/their/that player” resolve to the scoped (phase) player instead of an incorrect default.
    • Ensured “you/your” remains tied to the ability/controller context, keeping hand/life/damage (including scoped hand offsets) consistent.
  • Tests

    • Added and updated unit/integration coverage for Issue #6508, including Citadel of Pain each-player end step and Iron Maiden upkeep damage scenarios.

…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>
@jeffrey701
jeffrey701 requested a review from matthewevans as a code owner July 23, 2026 20:56
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The parser now captures player scope alongside where X clauses, threads ParseContext through quantity and effect rewriting, and applies scoped-player rewriting during each-player trigger lowering. Parser and integration tests cover controller, hand, life, damage, and negative-binding cases.

Changes

Scoped player binding

Layer / File(s) Summary
Where-X scope capture
crates/engine/src/parser/oracle_ir/effect_chain.rs, crates/engine/src/parser/oracle_effect/mod.rs
Clause IR and clause builders store optional where-X scope values derived from parsing context and propagate them through nested, delayed, absorbed, and continuation clauses.
Context-aware where-X lowering
crates/engine/src/parser/oracle_effect/lower.rs, crates/engine/src/parser/oracle_effect/{imperative,sequence,token}.rs, crates/engine/src/parser/oracle_quantity.rs
Where-X quantity, effect, filter, ability, and cost rewriting receives parsing context, while the context-free quantity entry point remains available for legacy callers.
Phase-scoped trigger lowering
crates/engine/src/parser/oracle_trigger.rs, crates/engine/src/parser/oracle_effect/assembly.rs
Scoped-player trigger references and where-X clause applications are rewritten using the captured scope.
Parser and gameplay regressions
crates/engine/src/parser/oracle_effect/lower.rs, crates/engine/src/parser/oracle_trigger_tests.rs, crates/engine/tests/integration/*6508.rs, crates/engine/tests/integration/main.rs
Tests cover scoped controller, hand, life, damage, and negative you control bindings across parser lowering and gameplay execution.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant ParseContext
  participant ClauseBuilder
  participant WhereXLowering
  participant TriggerLowering
  OracleText->>ParseContext: parse each-player and where-X text
  ParseContext->>ClauseBuilder: derive and store where_x_scope
  ClauseBuilder->>WhereXLowering: provide where-X expression and scope
  WhereXLowering->>TriggerLowering: resolve scoped quantities and filters
  TriggerLowering-->>OracleText: produce phase-scoped effects
Loading

Suggested labels: bug, quality

Suggested reviewers: matthewevans, ntindle

🚥 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 parser fix: scoping each-player phase-trigger anaphors to the phase player, with the Citadel of Pain regression as a concrete example.
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.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 23, 2026
@matthewevans

matthewevans commented Jul 23, 2026

Copy link
Copy Markdown
Member

Parse changes introduced by this PR · 10 card(s), 8 signature(s) (baseline: main daf5c1df7b2f)

🟡 Modified fields (8 signatures)

  • 2 cards · 🔄 ability/DealDamage · changed field amount: max(-1*target zone card count+3, 0)max(-1*cards in hand (scoped player)+3, 0)
    • Affected (first 3): Rackling, Wheel of Torture
  • 2 cards · 🔄 ability/DealDamage · changed field amount: target zone card count+-4cards in hand (scoped player)+-4
    • Affected (first 3): Iron Maiden, Viseling
  • 1 card · 🔄 ability/DealDamage · changed field amount: # of untapped you control land# of untapped scoped player controls land
    • Affected (first 3): Citadel of Pain
  • 1 card · 🔄 ability/DealDamage · changed field amount: max(-1*target zone card count+4, 0)max(-1*cards in hand (scoped player)+4, 0)
    • Affected (first 3): Storm World
  • 1 card · 🔄 ability/DealDamage · changed field amount: target zone card countcards in hand (scoped player)
    • Affected (first 3): Price of Knowledge
  • 1 card · 🔄 ability/LoseLife · changed field amount: (target zone card count + -1*cards in hand (you))(cards in hand (scoped player) + -1*cards in hand (you))
    • Affected (first 3): Dark Suspicions
  • 1 card · 🔄 ability/LoseLife · changed field amount: divide(life total (target player), 2, rounded up)divide(life total (scoped player), 2, rounded up)
    • Affected (first 3): Havoc Festival
  • 1 card · 🔄 ability/Mill · changed field count: target zone card countcards in hand (scoped player)
    • Affected (first 3): Dreamborn Muse

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

@matthewevans

Copy link
Copy Markdown
Member

Part B is clean and at the right seam — Part A is not. parse_where_x_quantity_expression hard-codes ScopedPlayer instead of threading the caller's ParseContext, and the new parse-diff proves the cost: three cards labelled "runtime-identical" are actually target-player anaphors moved from one wrong referent to another.

Reviewed at head 03c3aa6a (unchanged since the last look). Thank you for the parse-diff — it's exactly the evidence that made this reviewable, and it's what turned a predicted harm into a measured one.

📊 Parse-diff verdict: claimed 14, measured 13

Sticky comment 5063481753 (baseline 21a53d50cbcb): 13 cards / 12 signatures, all "Modified fields", none gained, none lost.

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 -1*cards in hand (you) controller operand.

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 ObjectCount, so it plausibly never reaches the touched arm. The sticky's trailing "2 card(s) had Oracle-text changes (errata/reprint) — excluded" bucket is unnamed, so this can't be reconciled from the sticky alone. Please account for it.

Unexplained #2 — the 3 "label-only" cards are target-player anaphors (Scryfall-verified):

Card Oracle Change
Jovial Evil "deals X damage to target opponent, where X is twice the number of white creatures that player controls" 2*# of white you control creature2*# of white scoped player controls creature
Curious Herd "Choose target opponent. You create X … where X is the number of artifacts that player controls" # of you control artifact# of scoped player controls artifact
Pact of the Serpent "Target player draws X cards and loses X life, where X is the number of creatures they control…" same shift, 2 signatures (Draw + LoseLife)

The correct referent for all three is ControllerRef::TargetPlayer. Calling this "runtime-identical for spells" understates it — it swaps one wrong binding for a different wrong binding.

🔴 Blockers

[HIGH] parse_where_x_quantity_expression hard-codes ScopedPlayer rather than threading ParseContext.

crates/engine/src/parser/oracle_effect/lower.rs:8145-8161 injects for_each_anaphor_context(&ParseContext::default(), &ControllerRef::ScopedPlayer) unconditionally, across ~30 non-test call sites (mana.rs:1071; token.rs:118/683/727; imperative.rs:8429/9958; counter.rs:239; 32 refs in lower.rs).

The sibling interpreter already does this correctly: crates/engine/src/parser/oracle_quantity.rs:3337 parse_for_each_clause_with_context takes &ParseContext and threads whichever scope the caller holds. And the pre-existing test for_each_they_control_threads_target_player (oracle_quantity.rs:6258, Burden of Greed) proves TargetPlayer binding already works today. The machinery to bind those three cards correctly exists; this change routes around it.

Suggested fix: add parse_where_x_quantity_expression_with_context(expr, ctx), thread effect_ctx from the where-X binding path, and leave the context-free signature delegating with ParseContext::default().

[MED] The "runtime-identical" justification cites the wrong resolver.

lower.rs:8149-8151 cites scoped_player_or_controller. Two functions share that name. The one the comment describescrates/engine/src/game/quantity.rs:3842 — is ability.scoped_player.unwrap_or(controller), a clean degrade. But this change's actual output for Citadel of Pain is ObjectCount { filter: Typed { controller: Some(ScopedPlayer) } }, whose runtime authority is crates/engine/src/game/filter.rs:1154filter.rs:780, and that path inserts an extra rung .or_else(|| triggering_event_player(state)) before source_controller, reading state.current_trigger_event / the DETECTION_TRIGGER_EVENT TLS (quantity.rs:1491).

Confidence: high that the comment is wrong about its own code path; medium that the divergence is reachable (current_trigger_event is save/restore-scoped at game/engine.rs:7915/7921 and game/triggers.rs:152/168, but game/combat.rs:13571 assigns AttackersDeclared with no paired restore in that block). Either fix the citation and prove the spell claim, or drop the claim.

[MED] Fixture path-divergence. All 4 runtime tests (tests/integration/citadel_of_pain_each_player_end_step_6508.rs) and all 4 shape tests (oracle_trigger_tests.rs) are phase triggers, so ability.scoped_player is always Some. The None arm at filter.rs:780 — the arm all three spell cards take in production — has zero fixtures.

[MED] Claimed parse impact (14) ≠ measured (13). Reconcile Inscription of Abundance.

✅ Clean — verified

  • Part B is at the right seam and needs no changes. parser/oracle_trigger.rs:1639-1652 adds the missing ScopedPlayer rung to the existing TargetPlayer/SourceChosenPlayer ladder in lower_trigger_ir and reuses the pre-existing rewrite_event_player_quantity_refs_to_scoped (oracle_effect/mod.rs:24608). Verified at mod.rs:24619-24639 that it rewrites only PlayerScope::Target and TargetZoneCardCount, never Controller — so Dark Suspicions survives, which the parse diff confirms. The Rack mutual-exclusion argument holds.
  • The class is real — 10 Scryfall-verified cards, well above the 3-card bar.
  • Nom mandate satisfied — no find()/split_once()/contains()/starts_with() parsing dispatch, no verbatim Oracle string match anywhere in the diff.
  • All 9 CR numbers grep-verified in docs/MagicCompRules.txt, and each describes the code it annotates: 109.5, 608.2c, 603.2b, 102.1, 513.1, 503.1a, 110.5, 107.3i, 115.1.
  • Runtime tests genuinely discriminate. T1 is asymmetric (P0=1/P1=3; revert flips −3→−1), T2 (P1 all tapped; revert flips 0→−2), T4 (Part B, 7−4=3 vs pre-fix 0). They drive GameScenario/GameRunneradvance_to_end_stepadvance_until_stack_empty and assert life deltas rather than AST shape. IRON_MAIDEN_ORACLE matches Scryfall verbatim.

🟡 Non-blocking

  • The PR body checks - [x] Required checks ran clean. while the required aggregator is red. The root cause is maintainer-side, so this isn't misreporting — but please keep the template honest.
  • The triage packet reports anchors: [] despite a well-formed "## Anchored on" section with two valid anchors; worth a look at why it isn't being picked up.

Recommendation

request-changes, contributor round-trip — not a maintainer fixup. Threading ParseContext through ~30 call sites is a design change, not a small local correction. Please:

  1. Thread the caller's ParseContext through parse_where_x_quantity_expression (this is the same fix flagged previously, now backed by three named misbound cards).
  2. Fix the filter.rs:780 citation, and either prove or drop the "runtime-identical for spells" claim.
  3. Add a fixture that reaches the None arm.
  4. Reconcile claimed 14 vs measured 13.

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: release: v0.35.2 bumped client/src-tauri/Cargo.toml without updating the separate client/src-tauri/Cargo.lock, redding Tauri compile check and through it the required Rust aggregator on every open PR. #6568 fixes it on main.

matthewevans added a commit that referenced this pull request Jul 23, 2026
…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>
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
…mp (phase-rs#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 phase-rs#6561 tauri-check PASSED at 20:43:00Z; the release commit
landed at 20:49:54Z; PRs phase-rs#6564 (20:56:21Z) and phase-rs#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>
@matthewevans matthewevans removed the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 25, 2026
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label 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.

Current head cb03323 remains blocked.

parse_where_x_quantity_expression still creates a default ParseContext and unconditionally installs ScopedPlayer instead of receiving and threading the caller's context. Consequently target-player anaphors such as Revelation of Power's sibling class are rebound to ScopedPlayer rather than TargetPlayer.

Add the context-aware entry point, thread the existing effect context through its callers, retain the default wrapper only for genuinely context-free callers, and cover a target-player where-X case before requesting review again.

…nstead of hardcoding ScopedPlayer (phase-rs#6564 review)

The phase-rs#6508 Part A install of a where-X anaphor scope hardcoded ScopedPlayer
in parse_where_x_quantity_expression, so a targeted spell's "that player
controls" / "they control" where-X was rebound to ScopedPlayer instead of
the caller's actual scope.

Make the interpreter context-aware:

- parse_where_x_quantity_expression_with_context(text, ctx) is the real
  entry point; the CDA arm binds the anaphor to ctx.relative_player_scope
  when a scope is stamped, and takes the exact pre-phase-rs#6508 parse_cda_quantity
  path (legacy caster-relative You) when it is not — never a hardcoded
  ScopedPlayer. parse_where_x_quantity_expression(text) is a thin default
  wrapper for the genuinely context-free boundary/shape probes.
- The where-X string is stripped to the IR and interpreted at the
  context-free assembly walk, so the parse-time scope is captured into a new
  ClauseIr.where_x_scope, rebuilt into a ParseContext at the top of
  apply_where_x_ability_expression, and threaded through the apply_/bind_
  where_x_* chain (including the recursive "N plus/minus <where-x>" self-call).
  Inline ctx-bearing callers (token.rs, imperative.rs) call the _with_context
  entry point directly.

Each-player / each-opponent phase triggers (Citadel of Pain, Iron Maiden,
Rackling, Dark Suspicions, Havoc Festival, Price of Knowledge) thread their
ScopedPlayer scope through and keep the correct binding. Targeted spells are
no longer rebound to ScopedPlayer; with no stamped scope they retain the
legacy You binding, byte-identical to main.

A spell's "that player" is frequently cross-clause (Curious Herd) or shared
across sibling sub-effects (Pact of the Serpent's "draws X and loses X"), so
auto-deriving TargetPlayer from a single clause's own target diverged the
two X operands (CR 107.3i single-value-of-X). That target carry-forward is a
separate change; the context-aware entry point already binds TargetPlayer
when a caller supplies that scope (covered by
where_x_that_player_controls_binds_caller_scope_not_scoped_player).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeffrey701

Copy link
Copy Markdown
Contributor Author

Done at head 2a97ab924parse_where_x_quantity_expression no longer creates a default context or installs ScopedPlayer; it receives and threads the caller's context.

What changed

  • parse_where_x_quantity_expression_with_context(where_x_expression, ctx: &ParseContext) is the real interpreter now; parse_where_x_quantity_expression(where_x_expression) is a thin wrapper over it with ParseContext::default(), kept only for the genuinely context-free callers (the boundary/is_some() probes in structurally_bound_where_x_clause, the Variable("X")-shape checks, etc.).
  • The CDA arm binds the anaphor to ctx.relative_player_scope when a scope is stamped, and takes the exact pre-Citadel of Pain — damages its controller instead of opponents #6508 parse_cda_quantity path (legacy caster-relative You) when it is not — never a hardcoded ScopedPlayer.
  • Because the where-X string is stripped to the IR and interpreted at the context-free assembly walk, the parse-time scope is captured into a new ClauseIr.where_x_scope and rebuilt into a ParseContext at the top of apply_where_x_ability_expression, then threaded through the apply_/bind_where_x_* chain (and the recursive "N plus/minus " self-call). The inline ctx-bearing callers (token.rs, imperative.rs) call the _with_context entry point directly.

Result:

  • Each-player / each-opponent phase triggers (Citadel of Pain, Iron Maiden, Rackling, Dark Suspicions, Havoc Festival, Price of Knowledge) — the trigger's ScopedPlayer scope threads in, so the count/amount stays ScopedPlayer, unchanged.
  • Targeted spells — no longer rebound to ScopedPlayer. With no stamped scope they retain the legacy caster-relative You binding, byte-identical to main. When a caller does supply a TargetPlayer scope, the anaphor binds TargetPlayer — this is exactly what the new unit test where_x_that_player_controls_binds_caller_scope_not_scoped_player covers (a TargetPlayer-scoped context yields a TargetPlayer count; the scope-free wrapper does not force ScopedPlayer).

Why I did not auto-derive TargetPlayer from a spell's own player target

I tried that first (derive TargetPlayer when the clause's effect targets a player). It regressed the CR 107.3i single-value-of-X invariant on the very cards it was meant to fix: a spell's "that player" is frequently cross-clause ("Choose target opponent. You create X … artifacts that player controls" — Curious Herd) or shared across sibling sub-effects ("Target player draws X and loses X, where X is … creatures they control" — Pact of the Serpent). The single-clause assembly view can only see one leg, so the derivation bound Pact's Draw.count to TargetPlayer but left the sibling LoseLife.amount at You — one X, two different players. Binding a chosen target forward to a later clause's / sibling's anaphor is a real carry-forward change, distinct from this PR's scope, so I've left those cards on main's You (not the ScopedPlayer regression) and can take the carry-forward as a follow-up if you'd like it in this class.

Verification

  • Full cargo test -p engine --lib and --test integration green.
  • Regenerated card-data: Citadel of Pain counts the phase player's untapped lands (ScopedPlayer); the Iron Maiden family stays ScopedPlayer; no targeted spell binds ScopedPlayer; non-anaphor where-X ("that card's mana value", "creatures you control") is unchanged.
  • New target-player unit test + the updated where_x_they_control_binds_scoped_player (now exercises the _with_context(ScopedPlayer) path — the old assertion tested the hardcoded install you asked removed); shared_where_x_binds_draws_and_loses_life_siblings (Pact) is green with both legs consistent.
  • Parser-combinator gate (Gate A) passes; branch up to date with current main.

@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: 1

🤖 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 14125-14132: Remove the stale truncated first documentation line
above the helper, leaving the subsequent CR 109.5 + CR 608.2c paragraph as the
sole documentation for the relative player scope behavior.
🪄 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: 6d7a70f7-6abf-4326-ba9a-7adf4dd05e88

📥 Commits

Reviewing files that changed from the base of the PR and between cb03323 and 2a97ab9.

📒 Files selected for processing (7)
  • crates/engine/src/parser/oracle_effect/assembly.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_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/token.rs
  • crates/engine/src/parser/oracle_ir/effect_chain.rs

Comment on lines +14125 to +14132
/// CR 109.5 + CR 608.2c: True when this clause's own effect targets a PLAYER, so a
/// CR 109.5 + CR 608.2c: Capture the player scope that a trailing where-X anaphor
/// ("they control" / "that player controls") must bind to for this clause. The
/// scope is exactly the `relative_player_scope` a trigger / for-each / fanout
/// setter stamped onto the parse context — `ScopedPlayer` for Citadel of Pain's
/// each-player phase count, `TargetPlayer` for a per-opponent fanout iterand, etc.
/// `None` leaves the legacy caster-relative (`You`) binding untouched, exactly as
/// on `main`; the context-aware entry point rebinds only when a scope is present.

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

Stale first doc line contradicts the helper's behavior.

Line 14125 is a truncated leftover from the pre-2a97ab924 predicate version ("True when this clause's own effect targets a PLAYER, so a") and is followed immediately by a second CR 109.5 + CR 608.2c: sentence that is the actual doc. Since the paragraph below explicitly documents that target-derivation was removed, the dangling line is misleading.

📝 Proposed doc fix
-/// CR 109.5 + CR 608.2c: True when this clause's own effect targets a PLAYER, so a
 /// CR 109.5 + CR 608.2c: Capture the player scope that a trailing where-X anaphor
 /// ("they control" / "that player controls") must bind to for this clause. The
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// CR 109.5 + CR 608.2c: True when this clause's own effect targets a PLAYER, so a
/// CR 109.5 + CR 608.2c: Capture the player scope that a trailing where-X anaphor
/// ("they control" / "that player controls") must bind to for this clause. The
/// scope is exactly the `relative_player_scope` a trigger / for-each / fanout
/// setter stamped onto the parse context — `ScopedPlayer` for Citadel of Pain's
/// each-player phase count, `TargetPlayer` for a per-opponent fanout iterand, etc.
/// `None` leaves the legacy caster-relative (`You`) binding untouched, exactly as
/// on `main`; the context-aware entry point rebinds only when a scope is present.
/// CR 109.5 + CR 608.2c: Capture the player scope that a trailing where-X anaphor
/// ("they control" / "that player controls") must bind to for this clause. The
/// scope is exactly the `relative_player_scope` a trigger / for-each / fanout
/// setter stamped onto the parse context — `ScopedPlayer` for Citadel of Pain's
/// each-player phase count, `TargetPlayer` for a per-opponent fanout iterand, etc.
/// `None` leaves the legacy caster-relative (`You`) binding untouched, exactly as
/// on `main`; the context-aware entry point rebinds only when a scope is present.
🤖 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 14125 - 14132,
Remove the stale truncated first documentation line above the helper, leaving
the subsequent CR 109.5 + CR 608.2c paragraph as the sole documentation for the
relative player scope behavior.

Co-authored-by: Jeffrey <158072326+jeffrey701@users.noreply.github.com>
Remove the truncated duplicate doc line above the complete player-scope documentation.

Co-authored-by: Jeffrey <158072326+jeffrey701@users.noreply.github.com>
@matthewevans

Copy link
Copy Markdown
Member

Maintainer update for current head 578035eb6de7be863fd3b6ec3281e25a689c73a5: the documentation-only fixup is pushed. This fresh head is held pending Card data's current parse-diff evidence and the remaining Rust CI checks. Approval/enqueue will resume once they settle.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer CI fixup is pushed at current head 1fcf74d014ee7539e6645a6f9e01179330ec07f3: it resolves the reported collapsible_match lint and updates the six affected IR snapshots for the new where_x_scope field. The next step is GitHub CI for this exact head; review/enqueue remains held until its Rust lint and test checks complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants