review: narrow decline-tail verb to discard, add 3-player fan-out test#6082
Conversation
phase-rs#6007) "Each opponent who didn't discard a nonland card this way loses 3 life" was parsed as an unconditional per-player imperative with no gate, so every opponent lost 3 life regardless of what they discarded. Add a subject-only decline-tail dispatcher parallel to the existing who-can't/doesn't/does arms, but keyed on the moved card's type rather than whether the parent action succeeded: `Not { ZoneChangedThisWay { filter } }`. Also teach the player-scope chain splitter to keep a zone-change-filtered sub-ability attached to its own iteration instead of detaching it as a once-after-all-iterations tail, mirroring the existing treatment for performed-gated decline bodies. Closes phase-rs#6007
Scope the new "who didn't <verb> a [filter] this way" dispatcher to "discard" only — the verb this grammatical construction is actually verified against (Kroxa). Widening to sacrifice/exile is a one-line change once a card prints that construction. Add a 3-player regression test proving each opponent's life loss is gated by their own discard, not a once-after-all-iterations aggregate.
There was a problem hiding this comment.
Code Review
This pull request resolves issue #6007 by implementing support for subject-only mandatory-filtered decline-tail sub-abilities, specifically targeting cards like Kroxa, Titan of Death's Hunger. The parser has been updated to correctly identify and extract 'who didn't' and 'who did not' clauses, preventing them from being orphaned. In the engine, effects gated on ZoneChangedThisWay are now kept within their local player-scoped chain instead of being detached, ensuring they evaluate independently per opponent. A comprehensive suite of integration tests has been added to verify these rules. There are no review comments, and I have no feedback to provide.
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.
Parse changes introduced by this PR · 2 card(s), 2 signature(s) (baseline: main
|
Co-authored-by: bitfathers94 <bitfathers94@users.noreply.github.com>
…tail Complements 0886c98's parser-unit-level test with a runtime resolve_ability_chain check: the Kynaios and Tiro of Meletis-style "who didn't <non-discard verb> ... this way draws a card" clause must still resolve as an actual Draw, not silently drop the action.
matthewevans
left a comment
There was a problem hiding this comment.
Verdict: changes requested — the implementation is promising, but the current changed-card class lacks runtime coverage.
🔴 Blocker
crates/engine/tests/integration/kroxa_titan_nonland_discard_life_loss.rs:159proves only theeach opponentfan-out. The current parse-diff record also changes Strongarm Tactics, whose verified Oracle text is “Each player discards a card. Then each player who didn't discard a creature card this way loses 4 life.” The new parser explicitly maps that form toPlayerFilter::All(crates/engine/src/parser/oracle_effect/lower.rs:3982-3994), but no runtime test exercises the controller-inclusive scope or its per-playerZoneChangedThisWaygate. Please add a Strongarm-style runtime case (including a creature discard, a noncreature discard, and an empty hand) and correct the comment claiming Kroxa is the only verified card (lower.rs:3969-3975).
The Kroxa coverage is a good regression test for the opponent scope, and the narrowed non-discard guard is a useful compatibility check.
Recommendation: add the sibling-class runtime test, then rerun the prescribed project verification.
…ment
matthewevans (CHANGES_REQUESTED): the parse-diff also changes Strongarm
Tactics ("Each player discards a card. Then each player who didn't discard
a creature card this way loses 4 life."), which maps to PlayerFilter::All
in strip_each_scope_who_didnt_verb_filter_this_way_subject, but only the
Kroxa opponent-scope case had runtime coverage. Adds a 3-player Strongarm
Tactics test (creature discard, noncreature discard, empty hand) covering
the controller-inclusive scope, and corrects the doc comment that implied
Kroxa was the only verified card for the "discard" verb.
matthewevans
left a comment
There was a problem hiding this comment.
Approved: the current head resolves the Strongarm Tactics all-player runtime coverage blocker. The Kroxa opponent path and Strongarm controller-inclusive path both exercise the per-player ZoneChangedThisWay condition.
Summary
Fixes the "each opponent who didn't discard a nonland card this way" misparse for Kroxa, Titan of Death's Hunger. Closes #6007
The clause was parsed as an ordinary, unconditional per-player imperative with no gate at all, so every opponent lost 3 life whenever Kroxa entered or attacked, regardless of what — or whether anything — they discarded.
Files changed
CR references
last_zone_changed_idsplayer_scopefan-outImplementation method (required)
Method: manual (parser dispatch + engine iteration-attachment fix), verified against the existing
strip_each_scope_who_cant_subject/strip_each_scope_who_doesnt_subject/strip_each_scope_who_does_subjectdecline-tail familyTrack
Developer
Verification
Required checks ran clean locally (Tilt unavailable in this environment; ran cargo directly).
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— 16840 passed; 0 failed; 7 ignoredcargo test -p engine --test integration— 3277 passed; 0 failed; 2 ignored (includes the 4 new Kroxa regression tests)cargo clippy -p engine --lib --tests -- -D warnings— clean, no warningscargo fmt --all— applied, no diff./scripts/check-parser-combinators.sh— Gate A PASS (see below)Gate A
Gate A PASS head=e73c70c1762aa268434efc09452ffa33955415db base=2f4608051c4d694d3ec3fd59c3e68c26d3746559
Anchored on
strip_each_scope_who_cant_subject, the mandatory-impossible decline-tail sibling this new dispatcher parallels (subject-only "each<scope>who ..." head,PlayerFilterscope detection, comma-optional body split)condition_depends_on_effect_performed, whose siblingcondition_depends_on_zone_change_this_way(already defined, used elsewhere for interactive-choice deferral) is now also consulted bydetach_after_player_scope_local_chainso aZoneChangedThisWay-gated sub-ability stays attached to its own per-player iteration instead of detaching as a once-after-all-iterations tailFinal review-impl
Self-reviewed inline against the universal lenses (correct seam, class vs single case, sibling coverage, vacuous-negative guard, edge cases): narrowed the new dispatcher's verb set from a speculative {discard, sacrifice, exile} to
discardonly, since sacrifice/exile are unverified against any real card printing this construction — widening is a one-line change once one exists. Added a 3-player regression test to pin the per-opponent iteration-attachment fix (the second, less obvious half of the bug: without it, the life-loss sub-ability detaches from the per-opponent Discard iteration and evaluates once against a stale/aggregate zone-change ledger instead of each opponent's own discard).Claimed parse impact
Validation Failures
None.
CI Failures
None.
Closes #6007