Skip to content

Fix Jagged Lightning - #6692

Open
keloide wants to merge 4 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-card-xnn8b1
Open

Fix Jagged Lightning#6692
keloide wants to merge 4 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-card-xnn8b1

Conversation

@keloide

@keloide keloide commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the dropped-target-count misparse (docs/parser-misparse-backlog.md root cause #15, "Multi-target / 'up to N' optionality or count dropped") for Jagged Lightning and its whole class.

"<source> deals N damage to each of <cardinality> <noun>" is a cardinality × noun grammar matrix — cardinality {bounded one or two / optional up to N / exact N|X} × noun {bare-plural targets (CR 115.4) / typed target <type> (CR 115.1a)}. Only two of the six cells were implemented, one each by strip_bounded_targets_placeholder and strip_optional_target_prefix. Every other cell fell through to Effect::DamageAll, silently turning a two-target spell into a board wipe:

"Jagged Lightning deals 3 damage to each of two target creatures."
  before:  DamageAll  { amount: Fixed(3), target: Typed[Creature] }, multi_target: null
  after:   DealDamage { amount: Fixed(3), target: Typed[Creature] }, multi_target: exact(2)

i.e. it dealt 3 damage to every creature on the battlefield, with nothing at runtime flagging it.

Rather than adding a third and fourth sibling stripper, this parameterizes the seam across both axes with one authority combinator (parse_each_of_target_distribution) and routes both damage-seam consumers through it, so the target filter and the announced count come from a single parse and cannot disagree. The count travels on ParseContext with an explicit set/reset lifecycle; extract_deal_damage_multi_target is demoted to a fallback and left byte-for-byte unchanged.

Backlog hygiene: six cards removed from root cause #15, with the section header, ranked-table row and header totals updated to match.

Files changed

  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_ir/context.rs
  • crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs (new)
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • docs/parser-misparse-backlog.md

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

  • CR 601.2c — the authorizing rule for the announced target count ("In some cases, the number of targets will be defined by the spell's text… Once the number of targets the spell has is determined, that number doesn't change").
  • CR 115.4 — the authorizing rule for the bare-plural noun: "two targets" and similar may be creatures, players, planeswalkers, or battles ⇒ TargetFilter::Any.
  • CR 115.1a — instant/sorcery targeting via "target [something]", for the typed arm.
  • CR 115.3 — the same target can't be chosen twice for one instance of "target" (cross-slot distinctness across the N slots).
  • CR 120.1 — damage, for Effect::DealDamage.
  • CR 704.5g — lethal damage / SBAs, asserted in the runtime test.

Note: the pre-existing CR 115.1d annotations near this code are a mis-citation (115.1d is about triggered abilities being targeted, not target counts). New code cites CR 601.2c instead; existing occurrences are deliberately left untouched for a separate sweep.

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 test -p engine --libtest result: ok. 17713 passed; 0 failed; 6 ignored

  • cargo test -p engine --test integrationtest result: ok. 4009 passed; 0 failed; 2 ignored

  • cargo clippy-strict — exit 0 (-D warnings)

  • ./scripts/gen-card-data.sh — exit 0, Generated client/public/card-data.json (94M, ~35479 cards)

  • Card-data diffed against a pre-change baseline export — exactly 17 cards changed, 0 added, 0 removed. No collateral movement.

Tilt was not running in this environment (tilt get uiresource clippy non-zero), so the direct-cargo fallback path from CLAUDE.md's risk-scaled cadence was used.

Gate A

Gate A PASS head=d3f3f536aac8b2a978f85711340a5c5863799950 base=b9238c8c8d488288aad13fba1dae239acced84f9

Base is upstream/main (the PR target branch), passed explicitly per the script's documented CI usage.

Anchored on

  • crates/engine/src/parser/oracle_effect/counter.rs:307 — the existing exact-count each of <N|X> target <type>MultiTargetSpec::exact(count) arm in the sibling PutCounter target parser. Same combinator family (parse_multi_target_count_expr + separator + peek(tag("target"))), same CR 601.2c annotation. This is precisely why Thrive and Rot-Curse Rakshasa parse correctly today while Jagged Lightning did not: the counter seam grew the exact axis and the damage seam never did.
  • crates/engine/src/parser/oracle_effect/lower.rs:6139strip_bounded_targets_placeholder, the existing bare-plural cardinality stripper that composes its noun off the BOUNDED_TARGET_CARDINALITIES const and returns (remainder, MultiTargetSpec). parse_bounded_target_cardinality mirrors its for-over-const + tag(stem) shape exactly, as that constant's own doc comment prescribes.
  • crates/engine/src/parser/oracle_effect/lower.rs:6197strip_optional_target_prefix, the existing up to NMultiTargetSpec::up_to cardinality stripper; the optional arm is its combinator form.
  • crates/engine/src/parser/oracle_effect/lower.rs:6350 — the existing terminated(tag(" tapped"), not(satisfy(|c: char| c.is_alphanumeric()))) word-boundary fence, mirrored by the bare-plural targets arm.
  • crates/engine/src/parser/oracle_effect/mod.rs:16517 — the existing let mut tentative_ctx = ctx.clone(); … *ctx = tentative_ctx; clone-and-commit wrapper on try_parse_multi_target_damage_chain, mirrored for try_parse_radiance_color_fanout_damage.

Final review-impl

Final review-impl PASS head=d3f3f536aac8b2a978f85711340a5c5863799950

Three independent review rounds ran against isolated fresh contexts, each handed only the diff plus CLAUDE.md and the skills. Rounds 1 and 2 returned findings; every one was addressed with code (commits 50d63c474 and d3f3f536a). Round 3 returned zero findings against this head.

Claimed parse impact

17 cards, all DamageAll/UnimplementedDealDamage with a correct MultiTargetSpec. Verified by regenerating card-data.json and diffing against a pre-change baseline:

Card Announced count
Jagged Lightning exact(2)
Swelter exact(2)
Twinstrike exact(2)
Furious Reprisal exact(2)
Pinnacle of Rage exact(2)
Betrayal at the Vault exact(2)
Firestorm exact(X)
Meteor Blast exact(X)
Spinning Wheel Kick exact(X)
Fall of the Titans up_to(2)
Chandra, Hope's Beacon up_to(2)
Jaya's Immolating Inferno up_to(3)
Jeska, Thrice Reborn up_to(3)
Shower of Coals up_to(3)
Myojin of Roaring Blades up_to(3)
Chandra, the Firebrand up_to(6)
Batroc the Leaper up_to(KickerCount)

Two are first-sentence-only fixes: Shower of Coals' Threshold replacement sentence and Twinstrike's Hellbent sentence were already dropped before this change and still are — verified untouched in the diff.

Cards that must not regress and did not (all verified unchanged): Dire-Strain Anarchist, Volatile Arsonist, Wrap in Flames, Slight Malfunction, Summon: Kujata, The Rollercrusher Ride, Smoldering Werewolf, Cast into the Fire, Dual Shot, Fire Shrine Keeper, Prismari Charm, Rowan Fearless Sparkmage, Sparkmage's Gambit, Storm of Steel, Volcanic Salvo.

Deliberately not fixed, and left honestly red: Drakuseth, Maw of Flames (each of up to two *other* targets — the bare-plural arm has nowhere to land CR 115.3 cross-slot distinctness, and its second damage clause is dropped upstream anyway; root cause #27), Nahiri's Wrath and Crackle with Power (the amount clause is unparsed, not the target count), and The Ultimate Nightmare of Wizards of the Coast® Customer Service.

Backlog: six cards removed from §15 (Batroc the Leaper, Chandra Hope's Beacon, Jagged Lightning, Jaya's Immolating Inferno, Pinnacle of Rage, Shower of Coals). §15 header 89→83 (live bullet count is exactly 83), ranked-table row 15 89→83, "Total card appearances" 4767→4761, "Distinct cards implicated" 4733→4727 — each of the six appears zero times elsewhere in the document, so both deltas are −6. Firestorm, Furious Reprisal, Myojin and Betrayal at the Vault appear under other root causes for unrelated defects and were deliberately left alone.

Scope Expansion

None. No new engine enum variant (MultiTargetSpec::{fixed, up_to, exact} all pre-exist); no runtime, frontend, AI, or multiplayer change; mtgish/ untouched. EachOfTargetNoun is a file-private two-arm parser classification enum with no serialized surface.

Validation Failures

Three process deviations, disclosed rather than papered over:

  1. The plan-review loop was closed after 3 rounds rather than run to zero gaps. Round 3 returned one card-fact blocker (a wrong target count for Shower of Coals) plus five precision defects. Because the account had already hit its monthly spend limit once, those were folded into the implementation brief as explicit written amendments instead of spending a fourth plan round-trip. The architecture itself was confirmed sound by all three plan reviewers and survived unchanged.
  2. The implementation agent was killed mid-run by the spend limit, leaving a TEMP-REVERT-PROBE (if true { …old code…; return; }) in lower.rs with the new implementation dead behind #[allow(unreachable_code)], two out-of-scope card-data pipeline artifacts modified (known-tokens.toml, mtgjson-vintage), and the backlog edit undone. All three were caught and corrected before the first commit; the probe removal is why the final verification was re-run from scratch.
  3. A known gap is documented, not silently accepted. The bare-damage chain recognizer (try_parse_multi_target_damage_chain) still drops the announced count for a chained each-of head. No printed card has that shape — the only AtomicCards.json entries pairing an "each of ⟨N⟩" head with a following "and" use "and/or" inside the target phrase and all parse with their count intact — so nothing regresses. The test asserts the current behaviour with a comment telling a future maintainer to flip it to exact(2) if the chain recognizer learns to thread the count. Threading it is a separate change against that recognizer, not this seam.

CI Failures

None. Two local failures during the run were environmental, not code: a cr733 integration test and gen-card-data.sh both failed under a full disk (No space left on device), and both passed after freeing build artifacts.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Oracle parsing for “each of N targets” / “up to N” / variable-count damage, including correct handling of bare-plural “two targets.”
    • Ensures the announced target count is bound to the correct part of the effect (avoids incorrect mass-damage/threshold behavior).
    • Preserves multi-target counts across compound “and …” continuations and prevents target-count state from leaking between clauses.
    • Enforces minimum/optional target-slot behavior during casting.
  • Tests

    • Added extensive parser regression coverage and a new integration test module covering Jagged Lightning, Meteor Blast, Pinnacle of Rage, and Fall of the Titans.
  • Documentation

    • Updated the parser misparse backlog totals and root-cause summary.

claude added 3 commits July 26, 2026 18:28
…gged Lightning)

"<source> deals N damage to each of <cardinality> <noun>" is a 3x2 grammar
matrix -- cardinality {bounded, optional, exact} x noun {bare-plural "targets"
(CR 115.4), typed "target <type>" (CR 601.2c)} -- of which only two cells were
implemented, one each by `strip_bounded_targets_placeholder` and
`strip_optional_target_prefix`. Every other cell fell through to
`Effect::DamageAll`, silently turning a two-target spell into a board wipe:
Jagged Lightning dealt 3 damage to EVERY creature instead of to each of two
chosen ones.

Parameterize the seam across both axes with one authority combinator
(`parse_each_of_target_distribution`) rather than adding sibling strippers, and
route both damage-seam consumers through it so the target filter and the
announced count come from a single parse and cannot disagree. The count travels
on `ParseContext` with an explicit set/reset lifecycle, so
`extract_deal_damage_multi_target` is demoted to a fallback and left unchanged.

CR 601.2c fixes the announced target count ("In some cases, the number of
targets will be defined by the spell's text"); CR 115.4 gives the bare plural
its damage target class (creature, player, planeswalker, or battle).

17 cards move from DamageAll to DealDamage with a correct MultiTargetSpec:
Jagged Lightning, Swelter, Twinstrike, Furious Reprisal, Pinnacle of Rage,
Firestorm, Meteor Blast, Fall of the Titans, Jaya's Immolating Inferno,
Shower of Coals, Myojin of Roaring Blades, Chandra the Firebrand,
Chandra Hope's Beacon, Jeska Thrice Reborn, Batroc the Leaper,
Betrayal at the Vault, Spinning Wheel Kick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TTbBQugiEc8PYTxUvE56w
…eculative and nested parses

Addresses review findings on the "each of <N> target(s)" damage head.

- `try_parse_radiance_color_fanout_damage` parsed against the real `ParseContext`
  and abandoned on three branches with the announced count still recorded, so a
  discarded attempt could hand its count to whatever clause committed next. It
  now clones and commits on success only, matching the shape
  `try_parse_multi_target_damage_chain` already uses.
- `try_split_damage_compound` re-enters `lower_imperative_clause` for the
  continuation, and that nested frame clears the side channel on entry. It now
  saves the primary head's count across the sub-parse and attaches it to the
  clause it returns -- this function returns ahead of the `take()` + DealDamage
  fixup, so the count had to be applied here or it was dropped.
- Correct the `ParseContext` doc comment, which claimed the compound
  continuation sites parse into a clone. Only the chain recognizer did.
- Name the one cardinality x noun cell the combinator deliberately does not
  cover: `other`/`another` on the bare-plural arm, where there is nowhere for CR
  115.3 cross-slot distinctness to land.

The bare-damage CHAIN recognizer still drops the count for a chained each-of
head. No printed card has that shape, and the test now documents it as a known
gap rather than asserting it as correct.

No parse output changes: the regenerated export still differs from the
pre-change baseline in exactly the same 17 cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TTbBQugiEc8PYTxUvE56w
…-of remainder

Second review round.

`parse_each_of_target_distribution` returned a left-trimmed remainder, but a
leading space is the compound-boundary marker every consumer of
`try_parse_damage_with_remainder` keys on -- `try_split_damage_compound` matches
`tag(" and ")` and explicitly does not trim. So a bare-plural head followed by a
second clause ("... to each of two targets and you gain 2 life") handed the
splitter "and you gain 2 life", the match failed, and the continuation was
silently dropped, while the same sentence with a typed noun split correctly.

Return the remainder untrimmed and trim only at the typed call site, which is
the arm that actually needs a phrase starting at "target ".

Also correct the Batroc pin test's comment: it claimed the card's backlog bullet
stays, but the regenerated export shows its root-cause-phase-rs#15 defect is genuinely
fixed and the bullet was retired with the rest. What remains untested there is
the runtime path, not the parse.

No parse output changes: still exactly the same 17 cards versus the pre-change
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TTbBQugiEc8PYTxUvE56w
@keloide
keloide requested a review from matthewevans as a code owner July 27, 2026 07:28
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 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: 7411eebf-24c2-4057-a9cf-a58576b80d2f

📥 Commits

Reviewing files that changed from the base of the PR and between d3f3f53 and e040da1.

📒 Files selected for processing (4)
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/tests/fixtures/integration_cards.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/lower.rs

📝 Walkthrough

Walkthrough

The Oracle parser now recognizes “each of” damage target cardinalities, preserves announced counts through clause lowering, prevents speculative or cross-clause state leakage, and adds parser and integration coverage for exact, variable, heterogeneous, and optional targeting.

Changes

Each-of damage target parsing

Layer / File(s) Summary
Cardinality and noun distribution parser
crates/engine/src/parser/oracle_effect/lower.rs
Adds cardinality×noun parsing for bounded, optional, and exact counts, including literal and variable quantities, typed target remainders, and bare-plural targets.
Damage count context lifecycle
crates/engine/src/parser/oracle_ir/context.rs, crates/engine/src/parser/oracle_effect/mod.rs
Stores announced damage target counts in ParseContext, consumes them for DealDamage, and preserves or clears them across speculative, compound, and sibling clause parsing.
Oracle parser regression coverage
crates/engine/src/parser/oracle_effect/tests.rs
Tests target-count binding, target filters, loyalty and compound damage clauses, variable quantities, and prevention of count leakage.
End-to-end damage resolution validation
crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs, crates/engine/tests/integration/main.rs, docs/parser-misparse-backlog.md
Adds integration coverage for exact, heterogeneous, variable-count, and optional targeting, registers the test module, and updates the corrected parser backlog entries.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant OracleParser
  participant ParseContext
  participant EffectLowerer
  participant GameRunner
  OracleText->>OracleParser: parse each-of cardinality and noun
  OracleParser->>ParseContext: store pending target count
  EffectLowerer->>ParseContext: consume pending target count
  EffectLowerer->>GameRunner: produce DealDamage with multi_target
  GameRunner->>GameRunner: resolve declared targets and damage
Loading

Suggested labels: quality

Suggested reviewers: matthewevans, mike-thedude

🚥 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 is related to the changeset because Jagged Lightning is one of the parser fixes, though the PR is broader than that single card.
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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
crates/engine/tests/fixtures/integration_cards.json

Checkov skipped this file: it is too large to scan (10838464 bytes)

🔧 ast-grep (0.44.1)
crates/engine/tests/fixtures/integration_cards.json

ast-grep skipped this file: it is too large to scan (10838464 bytes)

crates/engine/src/parser/oracle_effect/mod.rs

ast-grep timed out on this file


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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/engine/src/parser/oracle_effect/mod.rs (2)

14848-14867: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

pending_damage_multi_target is not actually the primary authority — the text-scanning block at Line 14852 runs first.

For a DealDamage clause with a target filter, the generic fixup at Lines 14852-14857 (extract_exact_target_multi_target / bounded / optional) already populates clause.multi_target, so by Line 14864 clause.multi_target.is_none() is false and the ctx spec is discarded. The comment at Lines 14859-14863 claims the opposite. Today this is invisible because the two authorities agree on the shapes covered by tests (Dire-Strain is exactly the both-fire case), but when they disagree the text scan silently wins — which is the failure mode this PR set out to remove.

Simplest fix: consume the ctx spec before the generic block.

🐛 Proposed reordering
+    // CR 601.2c + CR 115.4: the announced target count for a
+    // "⟨source⟩ deals N damage to each of ⟨count⟩ ⟨noun⟩" head comes from the
+    // SAME parse that produced the target filter, so it wins over any
+    // text-scanning extractor below.
+    if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() {
+        clause.multi_target = pending_damage_multi_target;
+    }
     if clause.multi_target.is_none()
         && triggers::extract_target_filter_from_effect(&clause.effect).is_some()
     {
         clause.multi_target = extract_exact_target_multi_target(text)
             .or_else(|| extract_bounded_target_multi_target(text))
             .or_else(|| extract_optional_target_multi_target(text));
     }
-    if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() {
-        clause.multi_target =
-            pending_damage_multi_target.or_else(|| extract_deal_damage_multi_target(text));
+    if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() {
+        clause.multi_target = extract_deal_damage_multi_target(text);
     }

A disagreement fixture (ctx spec vs. text scan producing different counts) would pin the intended precedence.

🤖 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 14848 - 14867,
Reorder the post-parse fixup blocks so the DealDamage branch consumes
pending_damage_multi_target before the generic target-filter extraction.
Preserve pending_damage_multi_target as the primary value for DealDamage
clauses, using extract_deal_damage_multi_target(text) only when the context
value is absent; leave the generic exact/bounded/optional extraction for clauses
not already assigned a count.

17184-17204: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The primary count is taken before the compound-shape guards and never restored on the bail paths.

take() at Line 17190 empties the side channel, but Lines 17192-17204 can still return None (empty remainder, no " and " connector, empty sub-text). On those paths try_split_damage_compound bails with the primary head's count already discarded from ctx, and the caller continues into parse_imperative_effect at Line 14833 relying on a re-parse to re-record it. That recovery is incidental, not guaranteed — any bail after the head has parsed loses the CR 601.2c count.

Either move the take() below the connector guards, or restore on bail:

🐛 Proposed fix
-    let primary_damage_multi_target = ctx.pending_damage_multi_target.take();
-
     if remainder.is_empty() {
+        return None;
+    }
+
+    // The remainder must start with " and " to be a compound connector.
+    // Do NOT trim — the leading space is the boundary marker.
+    let (after_and, _) = tag::<_, _, OracleError<'_>>(" and ")
+        .parse(remainder)
+        .ok()?;
+    let sub_text = after_and.trim();
+    if sub_text.is_empty() {
         return None;
     }
+    let primary_damage_multi_target = ctx.pending_damage_multi_target.take();

(and drop the duplicated guard block below)

🤖 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 17184 - 17204,
Preserve the primary damage target count when compound parsing exits through its
shape guards. In try_split_damage_compound, move the
pending_damage_multi_target.take() operation until after the remainder, " and "
connector, and non-empty sub_text checks, removing the now-duplicated lower
guard block; alternatively restore the captured value before every early None
return.
🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_effect/lower.rs (1)

12576-12599: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the second bounded cardinality (one, two, or three) to the matrix.

Only one or two is exercised, so the loop over BOUNDED_TARGET_CARDINALITIES is pinned for a single entry. A "one, two, or three targets" cell would also guard the comma-containing stem against future tokenization changes.

♻️ Suggested additional cell
+        // Cell 1b — the second bounded stem, incl. the comma-containing form.
+        assert_eq!(
+            parse_each_of_target_distribution("one, two, or three targets"),
+            Some((
+                MultiTargetSpec::fixed(1, 3),
+                EachOfTargetNoun::AnyTargets,
+                ""
+            ))
+        );
🤖 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 12576 - 12599,
Add a regression assertion alongside the existing bounded-cardinality cases for
“one, two, or three targets,” verifying it parses to MultiTargetSpec::fixed(1,
3), EachOfTargetNoun::AnyTargets, with an empty remainder; use the existing
parse_each_of_target_distribution test matrix and preserve the ordering guard.
🤖 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.

Outside diff comments:
In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 14848-14867: Reorder the post-parse fixup blocks so the DealDamage
branch consumes pending_damage_multi_target before the generic target-filter
extraction. Preserve pending_damage_multi_target as the primary value for
DealDamage clauses, using extract_deal_damage_multi_target(text) only when the
context value is absent; leave the generic exact/bounded/optional extraction for
clauses not already assigned a count.
- Around line 17184-17204: Preserve the primary damage target count when
compound parsing exits through its shape guards. In try_split_damage_compound,
move the pending_damage_multi_target.take() operation until after the remainder,
" and " connector, and non-empty sub_text checks, removing the now-duplicated
lower guard block; alternatively restore the captured value before every early
None return.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_effect/lower.rs`:
- Around line 12576-12599: Add a regression assertion alongside the existing
bounded-cardinality cases for “one, two, or three targets,” verifying it parses
to MultiTargetSpec::fixed(1, 3), EachOfTargetNoun::AnyTargets, with an empty
remainder; use the existing parse_each_of_target_distribution test matrix and
preserve the ordering guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3692ca02-6183-443d-aa64-3212cfeca77e

📥 Commits

Reviewing files that changed from the base of the PR and between 7a35ef2 and d3f3f53.

📒 Files selected for processing (8)
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_ir/context.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs
  • crates/engine/tests/integration/main.rs
  • docs/parser-misparse-backlog.md

@matthewevans matthewevans self-assigned this Jul 27, 2026
# Conflicts:
#	crates/engine/tests/fixtures/integration_cards.json
@matthewevans matthewevans added the bug Bug fix label Jul 27, 2026
@matthewevans

matthewevans commented Jul 27, 2026

Copy link
Copy Markdown
Member

Parse changes introduced by this PR · 17 card(s), 30 signature(s) (baseline: main 04e9c93c3c4f)

🟢 Added (11 signatures)

  • 2 cards · ➕ ability/DealDamage · added: DealDamage (amount=2, target=creature, targets=2-2)
    • Affected (first 3): Swelter, Twinstrike
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=2, target=any target, targets=0-3)
    • Affected (first 3): Shower of Coals
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=2, target=any target, targets=2-2)
    • Affected (first 3): Furious Reprisal
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=3, target=any target, targets=2-2)
    • Affected (first 3): Pinnacle of Rage
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=3, target=creature, targets=2-2)
    • Affected (first 3): Jagged Lightning
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=4, target=any target, targets=X-X)
    • Affected (first 3): Meteor Blast
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=6, kind=activated, target=any target, targets=0-6, timing=sorcery speed)
    • Affected (first 3): Chandra, the Firebrand
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=7, kind=activated, target=any target, targets=0-3)
    • Affected (first 3): Myojin of Roaring Blades
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=X, kind=activated, target=any target, targets=0-2, timing=sorcery speed)
    • Affected (first 3): Chandra, Hope's Beacon
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=X, kind=activated, target=any target, targets=0-3, timing=sorcery speed)
    • Affected (first 3): Jeska, Thrice Reborn
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=X, target=any target, targets=0-2)
    • Affected (first 3): Fall of the Titans

🔴 Removed (14 signatures)

  • 2 cards · ➖ ability/DamageAll · removed: DamageAll (amount=2, filter=creature)
    • Affected (first 3): Swelter, Twinstrike
  • 2 cards · ➖ ability/DamageAll · removed: DamageAll (amount=X, filter=any)
    • Affected (first 3): Fall of the Titans, Jaya's Immolating Inferno
  • 2 cards · ➖ ability/DamageAll · removed: DamageAll (amount=X, filter=any, kind=activated, timing=sorcery speed)
    • Affected (first 3): Chandra, Hope's Beacon, Jeska, Thrice Reborn
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=2, filter=any target)
    • Affected (first 3): Furious Reprisal
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=2, filter=any)
    • Affected (first 3): Shower of Coals
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=3, filter=any target)
    • Affected (first 3): Pinnacle of Rage
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=3, filter=creature)
    • Affected (first 3): Jagged Lightning
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=4, filter=any target)
    • Affected (first 3): Meteor Blast
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=6, filter=any, kind=activated, timing=sorcery speed)
    • Affected (first 3): Chandra, the Firebrand
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=7, filter=any, kind=activated)
    • Affected (first 3): Myojin of Roaring Blades
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=X, filter=any target)
    • Affected (first 3): Firestorm
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=self power, filter=any)
    • Affected (first 3): Batroc the Leaper
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=target's power, filter=any)
    • Affected (first 3): Betrayal at the Vault
  • 1 card · ➖ ability/DamageAll · removed: DamageAll (amount=target's power, filter=creature or planeswalker)
    • Affected (first 3): Spinning Wheel Kick
… 5 more signature(s) (5 card-changes) — showing first 5; see parse-diff.json

🟢 Added (5 signatures)

  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=X, target=any target, targets=0-3)
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=X, target=any target, targets=X-X)
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=self power, target=any target, targets=0-kicker payments for this spell)
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=target's power, target=another creature, targets=2-2)
  • 1 card · ➕ ability/DealDamage · added: DealDamage (amount=target's power, target=creature or planeswalker, targets=X-X)

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

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

Approved: current-head parser review is clean. The parameterized each-of damage target grammar is at the right lowering seam; the published parse diff accounts for the declared 17-card class, and the integration suite exercises exact, bare-target, X-count, and optional-target runtime paths.

@matthewevans
matthewevans added this pull request to the merge queue Jul 27, 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.

[MED] The multi-target damage-chain early return drops the primary announced target count. Evidence: crates/engine/src/parser/oracle_effect/mod.rs:16590-16597 records the count through try_parse_damage_with_remainder, but :16679-16686 constructs the returned ParsedEffectClause with multi_target: None; the caller returns before lower_imperative_clause reaches its take()/fallback at :14894-14922. The current regression test deliberately asserts that incorrect None at crates/engine/src/parser/oracle_effect/tests.rs:49715-49737. Why it matters: a spell shaped deals N damage to each of two target creatures and M damage to … gets one mandatory target instead of the text-defined two, contrary to CR 601.2c; the target-slot pipeline expands only when AbilityDefinition.multi_target is present (crates/engine/src/game/ability_utils.rs:4256-4284). Suggested fix: capture the primary pending_damage_multi_target in try_parse_multi_target_damage_chain_inner before parsing continuations and assign it to the returned primary clause; change the chain regression to require Some(MultiTargetSpec::exact(2)) while retaining the no-leak assertion for the following sentence.

[LOW] The new cardinality×noun matrix does not exercise its second shared bounded stem. Evidence: crates/engine/src/parser/oracle_effect/lower.rs:6110-6111 includes "one, two, or three", but the dedicated matrix at :12609-12641 asserts only "one or two". Why it matters: the comma-containing shared branch can regress without a matrix failure. Suggested fix: add a one, two, or three targets assertion expecting MultiTargetSpec::fixed(1, 3) (and, ideally, its typed-noun sibling).

@matthewevans matthewevans removed their assignment Jul 27, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 27, 2026
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.

3 participants