Skip to content

Fix Toxrill, the Corrosive — bind a counter anaphor to the recipient in per-recipient statics - #6335

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
minion1227:minion_5929
Jul 22, 2026
Merged

Fix Toxrill, the Corrosive — bind a counter anaphor to the recipient in per-recipient statics#6335
matthewevans merged 4 commits into
phase-rs:mainfrom
minion1227:minion_5929

Conversation

@minion1227

@minion1227 minion1227 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the counter-anaphor scope misparse for Toxrill, the Corrosive and its class.

CR 611.3a: a continuous effect from a static ability "isn't 'locked in'; it applies at any given moment to whatever its text indicates." So a bare anaphoric pronoun (it / them / him / her) in a per-recipient continuous static names the object receiving the effect, not the ability's source.

parse_counter_object_scope (oracle_nom/quantity.rs:1203) has no view of the enclosing static, so it bound every counter anaphor to ObjectScope::Source. Toxrill therefore scaled "-1/-1 for each slime counter on them" off Toxrill's own (always empty) slime pile, and the penalty never applied — the exact symptom reported.

The rebind is gated on the pronoun form, not merely on the affected set. The much larger Door of Destinies / Joraga Warcaller / Lion Sash class writes "…for each charge counter on ~", naming the source outright, where Source is already correct — a blanket rebind would have broken 32 cards to fix 5. A second test pins that class.

No new enum variant: ObjectScope::Recipient already existed and the layer evaluator already resolves it per affected object (layers.rs:6078). Only the recipient-varying classifier in quantity_expr_uses_recipient was missing its CountersOn arm. This is the quantity-axis twin of StaticCondition::RecipientHasCounters, documented in types/ability.rs:7329 as "the recipient analog of HasCounters" — both axes now agree on what the pronoun in "…counters on it" refers to.

Closes #5929

Files changed

  • crates/engine/src/parser/oracle_static/shared.rs
  • crates/engine/src/parser/oracle_static/mod.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/tests/integration/counter_anaphor_binds_to_recipient.rs
  • crates/engine/tests/integration/main.rs

CR references

  • CR 611.3a — a static's continuous effect applies to whatever its text indicates, so the pronoun rebinds to the current recipient
  • CR 613.4c — layer 7c, the effects/counters that modify power and/or toughness
  • CR 122.1 — a counter is a marker placed on an object

Implementation method (required)

Method: not-applicable — single-seam parser lowering fix; planned and implemented in-session against a regenerated 35k-card corpus rather than via the multi-agent pipeline.

Track

Developer

LLM

Model: claude-opus-4-8
Thinking: high

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all — clean

  • cargo clippy -p engine --all-targets --features proptest -- -D warnings — exit 0, no warnings

  • cargo test -p engine --libok. 17490 passed; 0 failed; 6 ignored

  • cargo test -p engine (integration) — ok. 3727 passed; 0 failed; 2 ignored

  • ./scripts/gen-card-data.sh — exit 0, 35452 cards, 31507/35482 supported (88.80%)

  • cargo semantic-audit — exit 0; zero findings for any of the 5 changed cards

  • cargo coverage — all 5 changed cards supported=true, gap_count=0; control class (Joraga Warcaller, Door of Destinies, Lion Sash, Hancock, Earthen Goo, Intrepid Adversary) also supported=true, gap_count=0

  • Revert-proof: commenting out the single bind_counter_anaphor_to_recipient call and re-running the new integration test fails at counter_anaphor_binds_to_recipient.rs:56 with left: (1, 1) right: (2, 2) — the source-scoped read applied Toxrill's own 3 slime counters uniformly to every affected creature. The test drives GameScenarioevaluate_layersderive_display_state, i.e. the production layer pipeline, not a parser-shape assertion.

Gate A

Gate A PASS head=d39c97130e68af2902e45cd6d67df34c3266717f base=cf3381e5a8ca97351a573eac5e510de892d78dc6

Anchored on

  • crates/engine/src/parser/oracle_static/mod.rs:223populate_active_zones_from_condition, the existing post-parse lowering transform in lower_static_ir that this one is registered beside, so every static builder inherits it from one authority
  • crates/engine/src/parser/oracle_static/shared.rs:461 — existing // CR 611.3a: bind the combat state to the recipient, not the source recipient-binding in this same module, same CR, same rebind direction
  • crates/engine/src/game/quantity.rs:711continuous_modification_dynamic_quantity, whose exhaustive no-wildcard arm list the new _mut mirror follows verbatim

Final review-impl

Final review-impl PASS head=d39c97130e68af2902e45cd6d67df34c3266717f

Self-review found and fixed one defect before this head: affected_names_the_source originally cited CR 604.2 for "an absent filter is self-scoped". CR 604.2 governs how long a static's continuous effect is active, not the default affected set — the citation did not describe the code, so it was replaced with a plain statement of the engine's Option<TargetFilter> representation. Two anaphora helpers were also retargeted from CR 608.2k (which scopes to cost/trigger-condition referents) to CR 611.3a, matching the convention already used at shared.rs:461.

Claimed parse impact

Regenerated the full corpus and audited every CountersOn scope in every static. Before this change zero cards parsed to a recipient-scoped counter read; after, exactly these 5 do, and no other card's scope changes:

  • Toxrill, the Corrosive — "Creatures you don't control get -1/-1 for each slime counter on them."
  • Clamavus — "Each creature you control gets +1/+1 for each +1/+1 counter on it."
  • Thelon of Havenwood — "Each Fungus creature gets +1/+1 for each spore counter on it."
  • Luxior, Giada's Gift — "Equipped creature gets +1/+1 for each counter on it."
  • Spark Rupture — "…is a creature with power and toughness each equal to the number of loyalty counters on it."

Corpus-wide scope distribution after the change: Source 90, Recipient 10 (5 cards × power+toughness). The 32-card explicit-~ class was asserted unchanged.

Validation Failures

None.

CI Failures

None.

Tier: Frontier

Summary by CodeRabbit

  • Bug Fixes

    • Fixed continuous static effects so counter references (including recipient-scoped “bare counter” anaphors) are evaluated per recipient.
    • Corrected “for each … counter(s) on …” parsing and resolution, ensuring explicit “counter on ~” stays source-referential while pronoun-based forms bind appropriately.
    • Improved separation of source vs recipient counter magnitudes when both are referenced in the same effect.
  • Tests

    • Added integration coverage for recipient-scoped “bare counter” anaphors in continuous effects.
    • Added cases verifying explicit “counter on ~” behavior and correct handling when both source and recipient counters are used.

… statics

CR 611.3a: a continuous effect from a static ability "isn't locked in; it
applies at any given moment to whatever its text indicates", so a bare
anaphoric pronoun ("it" / "them" / "him" / "her") in a per-recipient
continuous static names the object RECEIVING the effect, not the source.

`parse_counter_object_scope` (oracle_nom/quantity.rs) has no view of the
enclosing static, so it bound every counter anaphor to `ObjectScope::Source`.
Toxrill, the Corrosive therefore scaled "-1/-1 for each slime counter on them"
off Toxrill's own (always empty) slime pile, and the penalty never applied.

Rebinding happens once in `lower_static_ir` — the existing post-parse lowering
funnel that already runs `populate_active_zones_from_condition` — so anthem,
type_change and grammar builders all inherit it from a single authority
instead of each re-deriving the referent.

This is the quantity-axis twin of `StaticCondition::RecipientHasCounters`
(documented in types/ability.rs as "the recipient analog of `HasCounters`"),
so both axes now agree on what the pronoun in "...counters on it" refers to.
No new enum variant: `ObjectScope::Recipient` already existed and the layer
evaluator already resolves it per affected object — only the recipient-varying
classifier in `quantity_expr_uses_recipient` was missing its `CountersOn` arm.

The rebind is gated on the PRONOUN FORM, not merely on the affected set. The
much larger Door of Destinies / Joraga Warcaller / Lion Sash class writes
"...for each charge counter on ~", naming the source outright, where `Source`
is already correct; a blanket rebind would have broken 32 cards to fix 5.

Parse impact (regenerated card-data, whole 35k corpus): exactly 5 cards move
to a recipient-scoped counter read, and no other card's scope changes.
  Toxrill, the Corrosive - Clamavus - Thelon of Havenwood
  Luxior, Giada's Gift - Spark Rupture

Closes phase-rs#5929

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 22, 2026 11:31
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c50b715-e352-455e-bdc3-97bef889eb2e

📥 Commits

Reviewing files that changed from the base of the PR and between f2a3b51 and ee6c968.

📒 Files selected for processing (5)
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/parser/oracle_nom/quantity.rs
  • crates/engine/src/parser/oracle_quantity.rs
  • crates/engine/src/parser/oracle_static/anthem.rs
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/parser/oracle_static/anthem.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/parser/oracle_nom/quantity.rs
  • crates/engine/src/parser/oracle_quantity.rs

📝 Walkthrough

Walkthrough

Continuous static counter anaphors are parsed as deferred references, bound to source or affected recipients during static lowering, and resolved with recipient-aware quantity evaluation. Integration tests cover recipient-scoped, explicit source-scoped, and mixed counter references.

Changes

Counter anaphor binding

Layer / File(s) Summary
Parse deferred counter anaphors
crates/engine/src/parser/oracle_nom/quantity.rs, crates/engine/src/parser/oracle_quantity.rs, crates/engine/src/parser/oracle_static/anthem.rs, crates/engine/src/parser/oracle_static/shared.rs
Parses bare counter pronouns as deferred anaphors, preserves explicit ~ references as source-scoped, and routes relevant static and cost parsing through deferred entry points.
Bind anaphors during static lowering
crates/engine/src/parser/oracle_static/shared.rs, crates/engine/src/parser/oracle_static/mod.rs
Rewrites deferred counter references in continuous modifications and conditions to source or recipient scope based on the static’s affected filter.
Resolve quantities and validate behavior
crates/engine/src/game/quantity.rs, crates/engine/tests/integration/counter_anaphor_binds_to_recipient.rs, crates/engine/tests/integration/main.rs
Recognizes recipient-scoped counter dependencies, resolves anaphoric counters with context-aware lookup, and tests recipient, source, and mixed-scope effects.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: bug

Sequence Diagram(s)

sequenceDiagram
  participant OracleParser
  participant StaticLowering
  participant QuantityResolver
  participant GameState
  OracleParser->>StaticLowering: preserve and bind counter anaphor
  StaticLowering->>QuantityResolver: evaluate scoped quantity
  QuantityResolver->>GameState: read counters for resolved object
  GameState-->>QuantityResolver: counter value
Loading
🚥 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 directly describes the main fix: binding counter anaphors to recipients in per-recipient statics for Toxrill.
Linked Issues check ✅ Passed The PR addresses #5929 by deferring and rebinding counter anaphors so Toxrill's per-creature -1/-1 effect applies correctly.
Out of Scope Changes check ✅ Passed The parser, lowering, and test changes all support the counter-anaphor fix; no clearly unrelated code changes stand out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@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_static/shared.rs`:
- Around line 816-822: Update the logic around description_binds_counter_anaphor
and rebind_source_counters_to_recipient so it carries the recipient/anaphoric
scope on the parsed quantity and lowers only the counter operand associated with
that scope, rather than traversing every dynamic quantity in def.modifications.
Preserve explicit ~ counter reads unchanged when a static mixes them with
pronoun-backed reads, and add a regression test covering the mixed-scope case.
🪄 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: 3e4d79f9-3659-4d01-81d4-cf3998b89829

📥 Commits

Reviewing files that changed from the base of the PR and between df2ab2d and d39c971.

📒 Files selected for processing (5)
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/parser/oracle_static/mod.rs
  • crates/engine/src/parser/oracle_static/shared.rs
  • crates/engine/tests/integration/counter_anaphor_binds_to_recipient.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/parser/oracle_static/shared.rs Outdated
@matthewevans matthewevans self-assigned this Jul 22, 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.

Blocking: counter-source provenance is lost before static lowering

parse_counter_object_scope lowers it/them and ~ to the same ObjectScope::Source. The new static lowering then scans the whole description for any recipient pronoun and rewrites every CountersOn { object: Source, .. } in every dynamic modification. A static that reads counters on both ~ and on its recipient will therefore rebind the explicit source read to the recipient.

This needs per-quantity anaphoric provenance carried through parsing/lowering, rather than a description-wide post-pass. Please add a mixed source/recipient runtime regression; the Toxrill and Joraga cases do not cover that combination.

@matthewevans matthewevans removed their assignment Jul 22, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

…escription

Addresses the review on phase-rs#6335: the first commit inferred the referent by
scanning the static's whole description, so a static reading counters on BOTH
`~` and its recipient would rebind the explicit source read too. The referent
is now recorded per quantity at the parse site and bound at lowering.

CR 608.2k: `parse_for_each_counters_on_source` (oracle_nom/quantity.rs) parsed
the object with `parse_source_self_ref`, which accepts `~` AND "it" and
collapsed both to `ObjectScope::Source`. That collapse is where the provenance
was lost. The object is now dispatched: an explicit self-reference records
`Source`, a bare objective pronoun records the deferred `ObjectScope::Anaphoric`
— the variant the engine already documents for exactly this purpose and which
`rebind_anaphoric_object_scope` already lists `CountersOn` under.

`lower_static_ir` then binds each deferred read from the one place that knows
the antecedent, walking both the continuous modifications and the `UnlessPay`
condition tree (CR 118.12 — a combat tax carries its per-counter magnitude
there, not in a modification). A self-referential subject binds back to
`Source`, so Myr Prototype's AST is unchanged and its test needed no edit.

Every other entry point settles an unbound anaphor back to `Source` at its own
module boundary, so carrying provenance costs nothing outside the statics that
consume it. Verified absolutely on the regenerated 35k corpus: ZERO cards leave
an unbound `Anaphoric`, and the scope distribution is Source 495 / Recipient 8
/ Target 6 — every counter read outside the fixed cards is byte-identical.

Scope reduced honestly: Spark Rupture is no longer claimed. Its "equal to the
number of loyalty counters on it" text reaches a different combinator whose
provenance change would have churned 10 unrelated cards, so that path is left
exactly as it was and the card stays a known gap.

Adds the mixed source/recipient runtime regression the review asked for: one
static reading charge counters on `~` (+3/+0 from the source) and +1/+1
counters on "it" (+0/+4 from the recipient) must produce 9/10 on a 2/2 ally —
a single collapsed referent cannot produce that pair.

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

Copy link
Copy Markdown
Contributor Author

Thanks — the blocking finding was correct, and the description-wide post-pass is gone.

Where the provenance was actually lost. Not parse_counter_object_scope (that path feeds the "the number of … counters on it" form). The anthem class comes through parse_for_each_counters_on_source (oracle_nom/quantity.rs), which parsed the object with parse_source_self_ref — a combinator that accepts ~ and "it" and returned ObjectScope::Source for both. That collapse is the defect. The object is now dispatched:

  • explicit self-reference (~, "this Equipment") → ObjectScope::Source, bound at parse time
  • bare objective pronoun ("it" / "them" / "him" / "her") → ObjectScope::Anaphoric, deferred

ObjectScope::Anaphoric is the variant already documented for this, and rebind_anaphoric_object_scope already lists CountersOn under it — the counter combinator simply never emitted it.

Binding happens at lowering, per quantity. lower_static_ir walks the continuous modifications and the UnlessPay condition tree (CR 118.12 — a combat tax carries its per-counter magnitude there, not in a modification). A self-referential subject binds back to Source, which is why Myr Prototype's AST is unchanged and combat_tax_self_ref_subject_you_pay_per_counter needed no edit.

Mixed-scope regression added, as requested. One static reads charge counters on ~ and +1/+1 counters on "it":

MIXED PARSE => scope: Source      (the `~` read)
               scope: Recipient   (the "on it" read)

Runtime: a 2/2 ally with four +1/+1 counters, under a source holding three charge counters, resolves to 9/10 (+3/+0 from the source, +0/+4 from the recipient). A single collapsed referent cannot produce that pair. The Toxrill test still fails on revert with left: (1, 1) right: (2, 2).

Blast radius held to zero outside the fix. Every non-static entry settles an unbound anaphor back to Source at its own module boundary. Audited absolutely on the regenerated 35k corpus:

CountersOn scope distribution: Source 495, Recipient 8, Target 6
Cards leaving an UNBOUND Anaphoric: 0

So the only cards whose counter scope changes are the four fixed ones — Toxrill, Clamavus, Thelon of Havenwood, Luxior, Giada's Gift. Coverage total is identical (31507/35482, 88.7971%).

I checked this because an earlier revision did leak Anaphoric into 38 cards, and that is not behaviour-neutral: casting.rs's matches!(scope, ObjectScope::Source) and ability_rw::read_object_scope both treat Anaphoric differently from Source. Rather than widen those shared helpers (which would have moved other scopes too), the anaphor is now prevented from escaping at all.

Scope reduced honestly: Spark Rupture is no longer claimed. Its "power and toughness each equal to the number of loyalty counters on it" reaches the other combinator, and giving that one provenance churned 10 unrelated cards; that path is left exactly as it was and the card stays a known gap rather than riding along on unrelated churn.

Verification on f2a3b510a: cargo test -p engine --lib 17492 passed / 0 failed · integration 3728 passed / 0 failed · cargo clippy -p engine --all-targets --features proptest -D warnings clean · cargo semantic-audit no findings for any changed card · Gate A PASS head=f2a3b510a3230e8bb8080e7a52d2a151e6fe54b5.

@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.

🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_quantity.rs (1)

3943-3995: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add back a regression test for the plain (non-deferred) "counter on it" settle path.

The old test list covered parse_for_each_clause("counter on it") settling to CountersOn{Source}. The new tests only exercise the deferred entry point (parse_for_each_clause_deferred), so nothing pins that parse_for_each_clause/parse_for_each_clause_with_context still call settle_deferred_counter_anaphor_ref and collapse the pronoun back to Source for ordinary (non-static) callers.

✅ Suggested addition
     #[test]
     fn for_each_singular_counter_on_self() {
         ...
     }
+
+    /// Regression: the PLAIN (non-deferred) entry point must still settle the
+    /// bare pronoun back to Source for every non-static caller.
+    #[test]
+    fn for_each_counter_on_pronoun_settles_to_source_via_plain_entry() {
+        let qty = parse_for_each_clause("counter on it");
+        assert!(
+            matches!(
+                qty,
+                Some(QuantityRef::CountersOn {
+                    scope: ObjectScope::Source,
+                    counter_type: None,
+                })
+            ),
+            "plain entry must settle Anaphoric -> Source, got {qty:?}"
+        );
+    }
🤖 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_quantity.rs` around lines 3943 - 3995, Add a
regression test alongside for_each_any_counter_on_pronoun_defers that calls
parse_for_each_clause("counter on it") and asserts it settles to
QuantityRef::CountersOn with ObjectScope::Source and no counter type, preserving
the ordinary caller path through settle_deferred_counter_anaphor_ref while
keeping the deferred entry-point test unchanged.
🤖 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.

Nitpick comments:
In `@crates/engine/src/parser/oracle_quantity.rs`:
- Around line 3943-3995: Add a regression test alongside
for_each_any_counter_on_pronoun_defers that calls parse_for_each_clause("counter
on it") and asserts it settles to QuantityRef::CountersOn with
ObjectScope::Source and no counter type, preserving the ordinary caller path
through settle_deferred_counter_anaphor_ref while keeping the deferred
entry-point test unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 03c04e88-c90e-4696-88b5-cc5b962012cb

📥 Commits

Reviewing files that changed from the base of the PR and between d39c971 and f2a3b51.

📒 Files selected for processing (6)
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/parser/oracle_nom/quantity.rs
  • crates/engine/src/parser/oracle_quantity.rs
  • crates/engine/src/parser/oracle_static/anthem.rs
  • crates/engine/src/parser/oracle_static/shared.rs
  • crates/engine/tests/integration/counter_anaphor_binds_to_recipient.rs

@matthewevans matthewevans self-assigned this Jul 22, 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.

Reviewed current head ee6c968: counter anaphor provenance is now bound per quantity at static lowering; the mixed-scope runtime regression and the plain non-static settlement regression cover the two relevant boundaries.

@matthewevans matthewevans added the bug Bug fix label Jul 22, 2026
@matthewevans
matthewevans enabled auto-merge July 22, 2026 23:27
@matthewevans matthewevans removed their assignment Jul 22, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 22, 2026
Merged via the queue into phase-rs:main with commit 70e4f2d Jul 22, 2026
15 checks passed
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
…in per-recipient statics (phase-rs#6335)

* fix(parser): bind a counter anaphor to the recipient in per-recipient statics

CR 611.3a: a continuous effect from a static ability "isn't locked in; it
applies at any given moment to whatever its text indicates", so a bare
anaphoric pronoun ("it" / "them" / "him" / "her") in a per-recipient
continuous static names the object RECEIVING the effect, not the source.

`parse_counter_object_scope` (oracle_nom/quantity.rs) has no view of the
enclosing static, so it bound every counter anaphor to `ObjectScope::Source`.
Toxrill, the Corrosive therefore scaled "-1/-1 for each slime counter on them"
off Toxrill's own (always empty) slime pile, and the penalty never applied.

Rebinding happens once in `lower_static_ir` — the existing post-parse lowering
funnel that already runs `populate_active_zones_from_condition` — so anthem,
type_change and grammar builders all inherit it from a single authority
instead of each re-deriving the referent.

This is the quantity-axis twin of `StaticCondition::RecipientHasCounters`
(documented in types/ability.rs as "the recipient analog of `HasCounters`"),
so both axes now agree on what the pronoun in "...counters on it" refers to.
No new enum variant: `ObjectScope::Recipient` already existed and the layer
evaluator already resolves it per affected object — only the recipient-varying
classifier in `quantity_expr_uses_recipient` was missing its `CountersOn` arm.

The rebind is gated on the PRONOUN FORM, not merely on the affected set. The
much larger Door of Destinies / Joraga Warcaller / Lion Sash class writes
"...for each charge counter on ~", naming the source outright, where `Source`
is already correct; a blanket rebind would have broken 32 cards to fix 5.

Parse impact (regenerated card-data, whole 35k corpus): exactly 5 cards move
to a recipient-scoped counter read, and no other card's scope changes.
  Toxrill, the Corrosive - Clamavus - Thelon of Havenwood
  Luxior, Giada's Gift - Spark Rupture

Closes phase-rs#5929

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

* fix(parser): carry counter-anaphor provenance per quantity, not per description

Addresses the review on phase-rs#6335: the first commit inferred the referent by
scanning the static's whole description, so a static reading counters on BOTH
`~` and its recipient would rebind the explicit source read too. The referent
is now recorded per quantity at the parse site and bound at lowering.

CR 608.2k: `parse_for_each_counters_on_source` (oracle_nom/quantity.rs) parsed
the object with `parse_source_self_ref`, which accepts `~` AND "it" and
collapsed both to `ObjectScope::Source`. That collapse is where the provenance
was lost. The object is now dispatched: an explicit self-reference records
`Source`, a bare objective pronoun records the deferred `ObjectScope::Anaphoric`
— the variant the engine already documents for exactly this purpose and which
`rebind_anaphoric_object_scope` already lists `CountersOn` under.

`lower_static_ir` then binds each deferred read from the one place that knows
the antecedent, walking both the continuous modifications and the `UnlessPay`
condition tree (CR 118.12 — a combat tax carries its per-counter magnitude
there, not in a modification). A self-referential subject binds back to
`Source`, so Myr Prototype's AST is unchanged and its test needed no edit.

Every other entry point settles an unbound anaphor back to `Source` at its own
module boundary, so carrying provenance costs nothing outside the statics that
consume it. Verified absolutely on the regenerated 35k corpus: ZERO cards leave
an unbound `Anaphoric`, and the scope distribution is Source 495 / Recipient 8
/ Target 6 — every counter read outside the fixed cards is byte-identical.

Scope reduced honestly: Spark Rupture is no longer claimed. Its "equal to the
number of loyalty counters on it" text reaches a different combinator whose
provenance change would have churned 10 unrelated cards, so that path is left
exactly as it was and the card stays a known gap.

Adds the mixed source/recipient runtime regression the review asked for: one
static reading charge counters on `~` (+3/+0 from the source) and +1/+1
counters on "it" (+0/+4 from the recipient) must produce 9/10 on a 2/2 ally —
a single collapsed referent cannot produce that pair.

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

* test(PR-6335): cover plain counter anaphor settling

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
@minion1227
minion1227 deleted the minion_5929 branch July 26, 2026 11:43
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.

Toxril, the Corrosive — [[Toxrill, the Corrosive]] 's ability puts slime counters on enemies, however the slime counter…

2 participants