fix(parser): Kinetic Ooze p0 — bind 'any number of target creatures' on typed counter-doubling#5667
Conversation
…inetic Ooze p0)
Kinetic Ooze breaks the game (p0) on entry: casting raises 'Invalid action:
Unused selected target slots' at ChooseTarget. Its X>=10 ETB clause 'double the
number of +1/+1 counters on any number of other target creatures' lowered to a
single REQUIRED creature target instead of a variable 'any number' (min 0)
multi-target, so the deferred (X>=10) target slot did not match the resolved
chain's consumed slots.
extract_double_counter_multi_target (lower.rs) only matched the prefix 'double
the number of each kind of counter on ' and was only dispatched (mod.rs) for
Effect::Double { Counters }. A TYPED counter doubling ('+1/+1 counters') lowers
to Effect::MultiplyCounter and never reached the extractor, so
clause.multi_target stayed None.
- Broaden the extractor to consume 'double the number of <descriptor> counter(s)
on ' (descriptor = 'each kind of' via the singular arm, or a typed counter via
the plural arm), then apply the existing any-number-of-target check.
- Route Effect::MultiplyCounter to the same extractor.
The MultiplyCounter clause now carries multi_target { min: 0, max: None }; the
ETB resolves cleanly. +cast->resolve integration test (fails on main). Full
parser lib suite 7980 pass/0 fail.
Fixes phase-rs#5247
There was a problem hiding this comment.
Code Review
This pull request addresses issue #5247 by updating the extract_double_counter_multi_target parser to support typed counters and singular/plural variations, preventing panics on cards like Kinetic Ooze. An integration test has been added to verify this behavior. The review feedback correctly identifies a high-severity greedy matching bug in the parser where take_until can scan past clause boundaries, and provides an idiomatic fix using nom combinators to ensure robust parsing.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let (target_text, _) = preceded( | ||
| tag::<_, _, OracleError<'_>>("double the number of "), | ||
| alt(( | ||
| ( | ||
| take_until::<_, _, OracleError<'_>>(" counters on "), | ||
| tag(" counters on "), | ||
| ), | ||
| ( | ||
| take_until::<_, _, OracleError<'_>>(" counter on "), | ||
| tag(" counter on "), | ||
| ), | ||
| )), | ||
| ) | ||
| .parse(lower.as_str()) | ||
| .ok()?; |
There was a problem hiding this comment.
[HIGH] Greedy matching bug in extract_double_counter_multi_target due to unbounded take_until inside alt.
Why it matters
Using take_until inside alt to match " counters on " or " counter on " can scan arbitrarily far ahead and cross clause boundaries. For example, if the input is "double the number of each kind of counter on target creature with two +1/+1 counters on it", the first arm of alt (take_until(" counters on ")) will match all the way to the second clause's " counters on ", leaving "it" as the target_text. Swapping the arms causes a similar failure on inputs with a plural descriptor and a singular relative clause.
Suggested fix
Use take_until(" on ") to find the first " on " (which is guaranteed to be the boundary of the descriptor since no counter descriptor contains " on "), and then validate that the extracted descriptor ends with " counter" or " counters" using a non-greedy nom parser. Ensure that we propagate parsing errors directly instead of swallowing them with .ok().
let (target_text, descriptor) = preceded(
tag::<_, _, OracleError<'_>>("double the number of "),
terminated(
take_until::<_, _, OracleError<'_>>(" on "),
tag(" on "),
),
)
.parse(lower.as_str())?;
let _ = terminated(
take_until::<_, _, OracleError<'_>>(" counter"),
terminated(
alt((
tag::<_, _, OracleError<'_>>(" counters"),
tag::<_, _, OracleError<'_>>(" counter"),
)),
eof,
),
)
.parse(descriptor)?;References
- R1. Nom combinators on the first pass — no exceptions. Every new parser dispatch must use nom combinators and avoid greedy or fragile matching patterns. (link)
- Avoid swallowing all errors (e.g., using
.ok()) when only specific errors are expected to be handled or ignored. Propagate unexpected errors (such as invariant violations or invalid actions) to prevent masking critical bugs.
Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Approving — a p0 panic fix that routes a second effect into the existing extractor instead of forking a parallel one, with a claim that matches its measurement exactly. This is the shape these PRs should have.
✅ Verified, not taken on the body's word
- The card is real and the text is verbatim.
KINETIC_OOZEin the test matches Scryfall character-for-character, including "double the number of +1/+1 counters on any number of other target creatures." No paraphrase, so there is no room for a fabricated clause to survive. - CR 701.10e grep-verifies and is the right rule — "To double the number of a kind of counters on a player or permanent…" — cited exactly where the doubling is lowered.
- The parse-diff is exactly the claim, with zero collateral.
1 card · 1 signature · ability/MultiplyCounter · field targets: ∅ → 0+on Kinetic Ooze. This is the measurement that mattered most here: the fix widens a single verbatimtag("double the number of each kind of counter on ")into atake_untilclass match over any typed counter, and a widening like that is exactly where collateral hides. It bound one card. Claimed 1, measured 1. - The test discriminates.
issue_5247_kinetic_ooze_conditional_multi_target.rsdrives a realcast → resolvethroughGameScenarioand pins the actual failure (Invalid action: Unused selected target slots), not a proxy for it. It is registered intests/integration/main.rs, so it is not an inert false-green. - Right seam. Routing
Effect::MultiplyCounterinto the existingextract_double_counter_multi_target(mod.rs) rather than writing a second extractor is the correct call — one authority recovers theMultiTargetSpecfor both counter-doubling shapes. Thealtordering is also right: the plural" counters on "arm takes typed counters, and the singular" counter on "arm preserves the"each kind of counter on"form unchanged. - Combinator-first.
preceded/alt/take_until/tagthroughout, and everymatches!in the diff dispatches on enum variants (Effect::MultiplyCounter,Effect::Double), never on Oracle text.
🟡 Non-blocking
- The template gate flags you, and it is a formatting artifact — worth a 10-second fix next time. The audit gate reports
invalid_implementation_methodand wantsanchored on/gate a/final review-impl. Those three are/engine-implementerpipeline sections, and you honestly declared Not/engine-implementerwith a justification, so they do not apply and I have not recorded a template-gap signal against this PR. Themethod_valid: falseis almost certainly the bold markers inside the checkbox —- [x] **Not** \/engine-implementer`— defeating the parser. Dropping the**` should make the gate read your declaration correctly.
Recommendation: approve, label bug + quality, enqueue. bug because a p0 panic is wrong→right regardless of the fix(parser): title. quality because it is one-shot with no rework round, the body's card count equals the sticky's measured count on the same card, it extended an existing authority instead of forking a path, and it ships a discriminating test that fails on main. Closes #5247.
Fixes #5247.
Tier: Frontier
Model: claude-opus-4-8
Summary
Kinetic Ooze breaks the game (p0) when it enters: casting it raises
Invalid action: Unused selected target slotsatChooseTarget. Its X≥10 ETB clause, "double the number of +1/+1 counters on any number of other target creatures", lowered to a single required creature target instead of a variable "any number" (min 0) multi-target. This restores the multi-target bound so the clause offers 0-or-more targets and resolves cleanly.Implementation method (required)
/engine-implementerpipeline/engine-implementerRoot cause (CR 115.1d + CR 701.10e)
"any number of … target creatures" is a variable-count multi-target (min 0), recovered post-hoc by
extract_double_counter_multi_target(lower.rs). Two gaps kept it from firing on Kinetic Ooze:"double the number of each kind of counter on "— not a typed counter ("double the number of +1/+1 counters on …").mod.rs) only ran forEffect::Double { Counters }, but a typed counter ×2 lowers toEffect::MultiplyCounter, which was never routed to the extractor.So
clause.multi_targetstayedNone; theMultiplyCounterbound a single requiredTyped<Creature>. At resolution the deferred (X≥10) target slot then didn't match the chain's consumed slots → the panic.Fix
lower.rs) to consume"double the number of <descriptor> counter(s) on "— the descriptor is either"each kind of"(unchanged, via the singular" counter on "arm) or a typed counter (" counters on "). Then apply the existingany number of … targetcheck.MultiplyCounterto it (mod.rs) alongsideEffect::Double { Counters }.Verified parse: the
MultiplyCounterclause now carriesmulti_target { min: 0, max: None }(was absent).CR references
CR 115.1d ("any number of target" = min-0 multi-target), CR 701.10e (counter doubling), CR 601.2c (conditional target selection).
Verification
kinetic_ooze_x10_doubles_counters_on_other_target_creature_without_panic: casts Kinetic Ooze with X=10 targeting an other creature that has one +1/+1 counter, asserts the ETB resolves toPriority(no "Unused selected target slots" panic) and the creature's counters double 1 → 2. Fails onmain(panics).MultiplyCounterrouting regress none of the 53 "double the number of … counters on …" cards. Parser-combinator gate + clippy clean.