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>
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (3)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe parser now captures player scope with ChangesScoped player binding
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
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 |
|
Generated for head Parse changes introduced by this PR · 10 card(s), 8 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>
…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
left a comment
There was a problem hiding this comment.
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>
|
Done at head What changed
Result:
Why I did not auto-derive
|
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
crates/engine/src/parser/oracle_effect/assembly.rscrates/engine/src/parser/oracle_effect/imperative.rscrates/engine/src/parser/oracle_effect/lower.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/parser/oracle_effect/sequence.rscrates/engine/src/parser/oracle_effect/token.rscrates/engine/src/parser/oracle_ir/effect_chain.rs
| /// 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. |
There was a problem hiding this comment.
📐 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.
| /// 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>
|
Maintainer update for current head |
|
Maintainer CI fixup is pushed at current head |
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
|
Maintainer port and native-IR snapshot regeneration are pushed at current head The two snapshot changes were derived from the prior native CI snapshot output (Boseiju and Kroxa), not hand-predicted. Current CI and the current-head parse-diff are now running; this PR remains on hold until those exact-head artifacts settle. The old |
There was a problem hiding this comment.
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/lower.rs`:
- Around line 10441-10452: The where-X rewrite currently rebuilds an incomplete
ParseContext, losing subject and pending_choice_type needed by scoped CDA and
quantity/type parsing. Update apply_where_x_ability_expression and
apply_where_x_to_latest_def to accept and propagate &ParseContext, and make
for_each_anaphor_context derive its context while preserving those required
fields along with relative_player_scope.
🪄 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: 23734520-3bf5-41cf-b5a3-18fd83ca3f36
⛔ Files ignored due to path filters (4)
crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snapis excluded by!**/*.snap,!**/snapshots/**crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_of_the_veil_ir.snapis excluded by!**/*.snap,!**/snapshots/**crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__nashi_moon_sages_scion_ir.snapis excluded by!**/*.snap,!**/snapshots/**crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snapis excluded by!**/*.snap,!**/snapshots/**
📒 Files selected for processing (12)
crates/engine/src/parser/oracle_effect/assembly.rscrates/engine/src/parser/oracle_effect/imperative.rscrates/engine/src/parser/oracle_effect/lower.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/parser/oracle_effect/sequence.rscrates/engine/src/parser/oracle_effect/token.rscrates/engine/src/parser/oracle_ir/effect_chain.rscrates/engine/src/parser/oracle_quantity.rscrates/engine/src/parser/oracle_trigger.rscrates/engine/src/parser/oracle_trigger_tests.rscrates/engine/tests/integration/citadel_of_pain_each_player_end_step_6508.rscrates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (11)
- crates/engine/tests/integration/main.rs
- crates/engine/src/parser/oracle_effect/token.rs
- crates/engine/src/parser/oracle_effect/imperative.rs
- crates/engine/src/parser/oracle_quantity.rs
- crates/engine/src/parser/oracle_effect/assembly.rs
- crates/engine/src/parser/oracle_trigger.rs
- crates/engine/src/parser/oracle_trigger_tests.rs
- crates/engine/src/parser/oracle_effect/sequence.rs
- crates/engine/src/parser/oracle_ir/effect_chain.rs
- crates/engine/src/parser/oracle_effect/mod.rs
- crates/engine/tests/integration/citadel_of_pain_each_player_end_step_6508.rs
| where_x_scope: Option<&ControllerRef>, | ||
| ) { | ||
| // CR 109.5 + CR 608.2c: rebuild the parse-time player-anaphor scope captured on | ||
| // the clause into a `ParseContext` so every `parse_where_x_quantity_expression` | ||
| // reached from this def's rewrite walk reads "that player"/"they" against the | ||
| // correct player (`ScopedPlayer` phase player / `TargetPlayer` spell target). | ||
| // The context-free assembly walk lost the original `ParseContext`, so this is | ||
| // where it is reconstituted before threading down. | ||
| let wx_ctx = ParseContext { | ||
| relative_player_scope: where_x_scope.cloned(), | ||
| ..Default::default() | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect ParseContext fields and the anaphor-context helper.
ast-grep outline crates/engine/src/parser/oracle_ir/context.rs --items all
rg -nP -C 12 '\bfn for_each_anaphor_context\s*\(' crates/engine/src/parser/oracle_quantity.rs crates/engine/src/parser/oracle_quantity/
rg -nP -C 8 '\bfn parse_cda_quantity_with_context\s*\(' crates/engine/src/parser/
# Which ParseContext fields are read inside the CDA/anaphor path?
rg -nP -C 3 'ctx\.[a-z_]+' crates/engine/src/parser/oracle_quantity.rs | head -80Repository: phase-rs/phase
Length of output: 5826
🏁 Script executed:
#!/bin/bash
# Trace the context-aware quantity path and identify which ParseContext fields affect
# parsing or diagnostics after apply_where_x_ability_expression reconstructs wx_ctx.
sed -n '760,930p' crates/engine/src/parser/oracle_quantity.rs
sed -n '3450,3505p' crates/engine/src/parser/oracle_quantity.rs
sed -n '8920,9005p' crates/engine/src/parser/oracle_effect/lower.rs
sed -n '10430,10475p' crates/engine/src/parser/oracle_effect/lower.rs
rg -n -C 5 'parse_where_x_quantity_expression_with_context|for_each_anaphor_context|current_trigger_index|host_self_reference|ctx\.subject|ctx\.card_name' crates/engine/src/parser/oracle_quantity.rs crates/engine/src/parser/oracle_effect/lower.rsRepository: phase-rs/phase
Length of output: 37913
🏁 Script executed:
#!/bin/bash
# Inspect the parser invoked by for_each_anaphor_context and its context-dependent
# branches, then list all callers of the narrowing seam.
rg -n -C 12 'fn parse_type_phrase_with_ctx|parse_type_phrase_with_ctx\s*\(' crates/engine/src/parser
rg -n -C 8 'fn parse_(target|filter).*with_ctx|ctx\.(subject|card_name|host_self_reference|current_trigger_index)' crates/engine/src/parser/oracle_quantity.rs crates/engine/src/parser
rg -n -C 6 'apply_where_x_ability_expression\s*\(|apply_where_x_to_latest_def\s*\(' crates/engine/src/parser/oracle_effect/lower.rsRepository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from pathlib import Path
p = Path("crates/engine/src/parser/oracle_target.rs")
lines = p.read_text().splitlines()
start = next(i for i, line in enumerate(lines) if "pub fn parse_type_phrase_with_ctx" in line)
# The implementation ends before the next public function.
end = next((i for i in range(start + 1, len(lines))
if lines[i].startswith("pub ") and "parse_type_phrase_with_ctx" not in lines[i]), len(lines))
for i in range(start, end):
line = lines[i]
if "ctx." in line or "ParseContext" in line or "parse_type_phrase_with_ctx" in line:
print(f"{i+1}:{line}")
PY
printf '\nRelevant helper and seam callers:\n'
rg -n -C 5 'for_each_anaphor_context|apply_where_x_ability_expression|apply_where_x_to_latest_def' \
crates/engine/src/parser/oracle_quantity.rs \
crates/engine/src/parser/oracle_effect/lower.rsRepository: phase-rs/phase
Length of output: 17463
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from pathlib import Path
p = Path("crates/engine/src/parser/oracle_target.rs")
lines = p.read_text().splitlines()
start = next(i for i, line in enumerate(lines) if "pub fn parse_type_phrase_with_ctx" in line)
end = next((i for i in range(start + 1, len(lines))
if lines[i].startswith("pub ") and "parse_type_phrase_with_ctx" not in lines[i]), len(lines))
for i in range(start, end):
line = lines[i]
if "ctx." in line or "ParseContext" in line or "parse_type_phrase_with_ctx" in line:
print(f"{i+1}:{line}")
PY
printf '\nRelevant helper and seam callers:\n'
rg -n -C 5 'for_each_anaphor_context|apply_where_x_ability_expression|apply_where_x_to_latest_def' \
crates/engine/src/parser/oracle_quantity.rs \
crates/engine/src/parser/oracle_effect/lower.rsRepository: phase-rs/phase
Length of output: 17463
🏁 Script executed:
#!/bin/bash
sed -n '3260,3310p' crates/engine/src/parser/oracle_target.rs
sed -n '3495,3555p' crates/engine/src/parser/oracle_target.rs
sed -n '3655,3700p' crates/engine/src/parser/oracle_target.rs
sed -n '4525,4680p' crates/engine/src/parser/oracle_target.rs
sed -n '2050,2165p' crates/engine/src/parser/oracle_target.rsRepository: phase-rs/phase
Length of output: 21388
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from pathlib import Path
import re
lower = Path("crates/engine/src/parser/oracle_effect/lower.rs").read_text()
quantity = Path("crates/engine/src/parser/oracle_quantity.rs").read_text()
target = Path("crates/engine/src/parser/oracle_target.rs").read_text()
# Extract the reconstructed context literal.
seam = re.search(
r"let wx_ctx = ParseContext \{(?P<body>.*?)\n\s*\};",
lower,
re.S,
)
assert seam, "wx_ctx construction not found"
seam_fields = set(re.findall(r"^\s*([a-z_]+)\s*:", seam.group("body"), re.M))
# Extract the fields copied by the shared anaphor helper.
helper = re.search(
r"pub\(crate\) fn for_each_anaphor_context\(.*?\n\}",
quantity,
re.S,
)
assert helper, "for_each_anaphor_context not found"
helper_fields = set(re.findall(r"^\s*([a-z_]+)\s*:", helper.group(0), re.M))
# These are the context fields read or written by the type-phrase path.
type_phrase = target[target.index("pub fn parse_type_phrase_with_ctx"):
target.index("fn parse_controller_suffix")]
controller = target[target.index("fn parse_controller_suffix"):
target.index("/// Parse a type phrase")]
path_fields = sorted(set(re.findall(
r"\bctx\.([a-z_]+)", type_phrase + controller
)))
print("wx_ctx fields:", sorted(seam_fields))
print("for_each_anaphor_context copies:", sorted(helper_fields))
print("type/controller path accesses:", path_fields)
print("path accesses absent from wx_ctx:",
sorted(set(path_fields) - seam_fields))
print("path accesses absent from helper copy:",
sorted(set(path_fields) - helper_fields))
assert "subject" in path_fields and "subject" not in seam_fields
assert "pending_choice_type" in path_fields and "pending_choice_type" not in seam_fields
assert "target_chooser" in path_fields and "target_chooser" not in seam_fields
PYRepository: phase-rs/phase
Length of output: 726
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from pathlib import Path
import re
lower = Path("crates/engine/src/parser/oracle_effect/lower.rs").read_text()
quantity = Path("crates/engine/src/parser/oracle_quantity.rs").read_text()
target = Path("crates/engine/src/parser/oracle_target.rs").read_text()
seam = re.search(
r"let wx_ctx = ParseContext \{(?P<body>.*?)\n\s*\};",
lower,
re.S,
)
assert seam, "wx_ctx construction not found"
seam_fields = set(re.findall(r"^\s*([a-z_]+)\s*:", seam.group("body"), re.M))
helper = re.search(
r"pub\(crate\) fn for_each_anaphor_context\(.*?\n\}",
quantity,
re.S,
)
assert helper, "for_each_anaphor_context not found"
helper_fields = set(re.findall(r"^\s*([a-z_]+)\s*:", helper.group(0), re.M))
type_phrase = target[target.index("pub fn parse_type_phrase_with_ctx"):
target.index("fn parse_controller_suffix")]
controller = target[target.index("fn parse_controller_suffix"):
target.index("/// Parse a type phrase")]
path_fields = sorted(set(re.findall(
r"\bctx\.([a-z_]+)", type_phrase + controller
)))
print("wx_ctx fields:", sorted(seam_fields))
print("for_each_anaphor_context copies:", sorted(helper_fields))
print("type/controller path accesses:", path_fields)
print("path accesses absent from wx_ctx:",
sorted(set(path_fields) - seam_fields))
print("path accesses absent from helper copy:",
sorted(set(path_fields) - helper_fields))
assert "subject" in path_fields and "subject" not in seam_fields
assert "pending_choice_type" in path_fields and "pending_choice_type" not in seam_fields
assert "target_chooser" in path_fields and "target_chooser" not in seam_fields
PYRepository: phase-rs/phase
Length of output: 726
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from pathlib import Path
import re
lower = Path("crates/engine/src/parser/oracle_effect/lower.rs").read_text()
quantity = Path("crates/engine/src/parser/oracle_quantity.rs").read_text()
target = Path("crates/engine/src/parser/oracle_target.rs").read_text()
seam = re.search(r"let wx_ctx = ParseContext \{(.*?)\n\s*\};", lower, re.S)
assert seam
seam_fields = set(re.findall(r"^\s*([a-z_]+)\s*:", seam.group(1), re.M))
helper = re.search(
r"pub\(crate\) fn for_each_anaphor_context\(.*?\n\}",
quantity,
re.S,
)
assert helper
helper_fields = set(re.findall(
r"^\s*(relative_player_scope|subject|card_name|host_self_reference|current_trigger_index)\s*:",
helper.group(0), re.M
))
type_start = target.index("pub fn parse_type_phrase_with_ctx")
controller_start = target.index("fn parse_controller_suffix")
power_start = target.index("fn parse_power_suffix")
path = target[type_start:controller_start] + target[controller_start:power_start]
path_fields = sorted(set(re.findall(r"\bctx\.([a-z_]+)", path)))
print("wx_ctx fields:", sorted(seam_fields))
print("for_each_anaphor_context copies:", sorted(helper_fields))
print("type/controller path accesses:", path_fields)
print("path accesses absent from wx_ctx:",
sorted(set(path_fields) - seam_fields))
print("path accesses absent from helper copy:",
sorted(set(path_fields) - helper_fields))
assert {"subject", "pending_choice_type", "target_chooser"} <= set(path_fields)
assert {"subject", "pending_choice_type", "target_chooser"} - seam_fields
PYRepository: phase-rs/phase
Length of output: 658
Preserve the full where-X parse context.
wx_ctx retains only relative_player_scope, but the scoped CDA path uses subject and pending_choice_type; for_each_anaphor_context also resets required fields. Pass &ParseContext through apply_where_x_ability_expression and apply_where_x_to_latest_def, then preserve the fields required by the quantity/type parser when deriving the anaphor context.
🤖 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/lower.rs` around lines 10441 - 10452,
The where-X rewrite currently rebuilds an incomplete ParseContext, losing
subject and pending_choice_type needed by scoped CDA and quantity/type parsing.
Update apply_where_x_ability_expression and apply_where_x_to_latest_def to
accept and propagate &ParseContext, and make for_each_anaphor_context derive its
context while preserving those required fields along with relative_player_scope.
|
Maintainer snapshot repair is pushed at current head This commit updates only the three CI-reported native IR snapshots, using the exact values emitted by run The PR is held pending CI and the current-head parse-diff artifact. The current CodeRabbit review also has one unresolved context-propagation finding in |
matthewevans
left a comment
There was a problem hiding this comment.
Current head has one parser-context blocker — request changes.
🔴 Blocker
crates/engine/src/parser/oracle_effect/lower.rs:10438-10502 reconstructs wx_ctx from only where_x_scope; ClauseIr likewise preserves only that field at crates/engine/src/parser/oracle_ir/effect_chain.rs:834-843. This drops the parse context required by the lowerer's own typed quantity path. In particular, parse_type_phrase_with_ctx selects IsChosenCreatureType instead of IsChosenCardType from ctx.pending_choice_type at crates/engine/src/parser/oracle_target.rs:3285-3299, but for_each_anaphor_context receives the reconstructed default at crates/engine/src/parser/oracle_quantity.rs:3470-3490. Thus a where-X count over cards of the chosen type following a creature-type choice can be lowered on the card-type axis, even though the surrounding chain had already established the creature-type choice.
The present regression at crates/engine/src/parser/oracle_effect/tests.rs:4316-4347 cannot detect this: it parses from the default context and uses a creatures base, which always selects the creature-type axis. Preserve the semantic context that the quantity/type path consumes across ClauseIr -> assembly (rather than reconstructing a default context), and add a discriminator with a preceding creature-type choice plus a cards of the chosen type where-X count. It must assert the typed filter axis and fail when the propagated context is removed.
✅ Maintainer fixup
The three snapshot-only additions introduced at cb359e2 were removed at this head because the exact-head CI diff expected where_x_scope to be absent when where_x_expression is null. CI is rerunning for that maintenance-only repair; it does not resolve the context loss above.
Recommendation: request changes for context preservation and a discriminating parser/runtime test; then re-review the new head.
|
Correction to the current-head review: its snapshot note was wrong. Exact-head CI run Those are maintainer-created snapshot metadata failures and are separate from the parser-context blocker in |
|
The current head This is an expiry warning under the repository's requested-changes policy. Please address that review in a new commit within 7 days; otherwise this PR will be auto-closed. A new head or substantive follow-up will return it to review. |
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-Xplayer-scope binding in per-player triggers so phrases like “they,” “their,” and “that player” resolve to the scoped phase player instead of an incorrect default.Tests