Skip to content

fix(parser): keep both targets of the two-target fight class (Tail Swipe) - #5583

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
jaytbarimbao-collab:fix/two-target-pump-fight
Jul 12, 2026
Merged

fix(parser): keep both targets of the two-target fight class (Tail Swipe)#5583
matthewevans merged 4 commits into
phase-rs:mainfrom
jaytbarimbao-collab:fix/two-target-pump-fight

Conversation

@jaytbarimbao-collab

@jaytbarimbao-collab jaytbarimbao-collab commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #4751.

Tier: Frontier
Model: claude-opus-4-8

Summary

The two-target fight class — "Choose target creature you control and target creature you don't control. [buff the you-control creature]. Then those creatures fight each other." — silently dropped its second target whenever the buff was a pump (gets +N/+M). Tail Swipe "half worked" (only the you-control creature was targeted). Affects Tail Swipe, Joust, Blizzard Brawl (the counter siblings Malamet Battle Glyph / Longstalk Brawl / Duel for Dominance already worked). This bounds the continuous-verb scan to the current sentence and rekeys the buff to the first-declared (you-control) slot.

Implementation method (required)

  • Produced via the /engine-implementer pipeline
  • Not /engine-implementer

A targeted, review-driven parser fix scoped to one recognizer (starts_target_continuous_clause_lower) and one lowering pass (rewrite_two_target_counter_chain). Verified directly against the real card-data.json entries for all six class cards and pinned with parser unit tests; no new engine variants or effect semantics.

Root cause (CR 601.2c)

starts_target_continuous_clause_lower recognises a target <noun> gets/gains/has/loses … continuous clause via take_until(" gets ") — but the scan ran across the sentence period. For these cards the gets +N/+M verb lives in a later sentence, so the scan reached past the . and mis-classified the two-target declaration's second slot as a continuous clause. The clause splitter then bisected target A and target B at its and, and the orphaned target creature you don't control chunk fell through to Effect::unimplemented("target", …). Counter cards have no such verb, so the gap was Pump-specific.

Fix

  1. Bound the continuous-verb scan to the current sentence (stop at the first ". "), so a gets/gains/has/loses in a later sentence no longer pulls the two-target declaration apart (sequence.rs). Skulduggery (a genuine same-sentence two-target-continuous) still splits correctly.
  2. CR 611.2c — rekey the buff to slot 0. Extend the existing two-target buff rekey (previously PutCounter-only) to the other buff shapes the class uses: Pump{Any|ParentTarget} (Tail Swipe / Joust) and a SelfRef-affected GenericEffect{target:None} (Blizzard's "gets +N/+M and gains indestructible"). Both bind slot 0 (the first-declared you-control creature) instead of an unscoped whole-board target (lower.rs).
  3. Guard encodes the class. The rekey now fires only when the chain declares ≥ 2 TargetOnly slots and contains an Effect::Fight node (CR 701.14a) — so the widened matcher is safe by construction, not by the current pool happening not to trip it.

The reciprocal "those creatures fight each other" object stays ParentTarget by design (parse_fight_target, CR 701.14), so the fight itself needs no rekey.

CR references

CR 601.2c (multiple instances of "target"), CR 611.2c (continuous-effect affected set fixed at creation), CR 701.14a (Fight).

Verification

  • All six class cards parse with no residual Unimplemented and the buff bound to slot 0 — checked against the real card-data.json entries (Malamet, Longstalk, Duel, Tail Swipe, Joust, Blizzard Brawl).
  • Life at Stake / Mages' Contest (the review's collateral question): both are unimplemented on main and this branch; the only delta is a stray Unimplemented("you") head re-chunking — no functional chooser is created or destroyed. Pinned by life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub.
  • New tests: two_target_fight_pump_keeps_both_slots_and_buffs_slot_zero, life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub. Full parser suite green; parser-combinator gate + clippy clean.

…ipe)

"Choose target creature you control and target creature you don't control.
[buff the you-control creature]. Then those creatures fight each other."
(phase-rs#4751 — Tail Swipe, Joust, Blizzard Brawl, plus the already-supported counter
siblings Malamet Battle Glyph, Longstalk Brawl, Duel for Dominance) silently
dropped its SECOND target when the buff was a pump ("gets +N/+M"). The card
"half worked": only the you-control creature was ever targeted.

Root cause (CR 601.2c): `starts_target_continuous_clause_lower` recognises a
"target <noun> gets/gains/has/loses ..." continuous clause by scanning with
`take_until(" gets ")` — but that scan ran ACROSS the sentence period. For these
cards the "gets +N/+M" verb lives in a LATER sentence ("... you don't control.
The creature you control gets +1/+1"), so the scan reached past the period and
mis-classified the two-target DECLARATION's second slot as a continuous clause.
The clause splitter then bisected "target A and target B" at its "and", and the
orphaned "target creature you don't control" chunk fell through to
`Effect::unimplemented("target", ...)`. Counter cards (Malamet: "put a +1/+1
counter on it") have no such verb, so they were unaffected — which is why the
gap was Pump-specific and previously deferred.

Fix, in three parts:
- Bound the continuous-verb scan to the current sentence (stop at the first
  ". "), so a "gets/gains/has/loses" in a later sentence no longer pulls the
  two-target declaration apart. Skulduggery ("target creature you control gets
  +1/+1 and target creature an opponent controls gets -1/-1") — a genuine
  same-sentence two-target-continuous — still splits correctly.
- Extend the existing two-target buff rekey (previously `PutCounter`-only) to the
  two remaining buff shapes the class uses: `Pump{Any|ParentTarget}` (Tail Swipe
  / Joust) and a SelfRef-affected `GenericEffect{target:None}` (Blizzard's "gets
  +N/+M and gains indestructible"). Both bind slot 0 — the first-declared
  you-control creature — instead of an unscoped whole-board target. The rekey is
  gated on the chain declaring >= 2 TargetOnly slots, so single-target pumps and
  global anthems are untouched.

CR 611.2c: the reciprocal "those creatures fight each other" object stays
`ParentTarget` by design (`parse_fight_target`), so the fight itself needs no
rekey.

Verified: all six class cards parse with no residual Unimplemented and the buff
bound to slot 0 (checked against the real card-data.json entries); the full
parser test suite (7919) is green; Skulduggery and a global anthem are
unregressed. New test: `two_target_fight_pump_keeps_both_slots_and_buffs_slot_zero`.

Fixes phase-rs#4751.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request generalizes the two-target fight buff rekeying logic to support Pump and GenericEffect shapes alongside PutCounter, and bounds the continuous clause verb scan to the current sentence to prevent mis-parsing two-target declarations. The feedback suggests renaming the generalized functions to reflect that they now handle all buffs, and correcting the nom parser in starts_target_continuous_clause_lower to return the properly advanced remainder instead of the unadvanced input.

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.

Comment on lines +2243 to +2254
let segment = match take_until::<_, _, OracleError<'_>>(". ").parse(rest) {
Ok((_, seg)) => seg,
Err(_) => rest,
};
let _ = alt((
value((), (take_until(" gets "), tag(" gets "))),
value((), (take_until(" gains "), tag(" gains "))),
value((), (take_until(" has "), tag(" has "))),
value((), (take_until(" loses "), tag(" loses "))),
))
.parse(rest)
.parse(segment)?;
Ok((rest, ()))

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.

high

[HIGH] Ensure standard nom parser discipline by returning the correct remaining input. Evidence: crates/engine/src/parser/oracle_effect/sequence.rs:2243-2254.
Why it matters: Returning the unadvanced rest violates standard nom parser expectations and could cause silent parsing bugs or infinite loops if future callers rely on the consumed remainder. Suggested fix: Calculate the consumed length from the segment and slice rest accordingly.

    let segment = match take_until::<_, _, OracleError<'_>>(". ").parse(rest) {
        Ok((_, seg)) => seg,
        Err(_) => rest,
    };
    let (remaining_in_segment, _) = alt((
        value((), (take_until(" gets "), tag(" gets "))),
        value((), (take_until(" gains "), tag(" gains "))),
        value((), (take_until(" has "), tag(" has "))),
        value((), (take_until(" loses "), tag(" loses "))),
    ))
    .parse(segment)?;
    let consumed_len = segment.len() - remaining_in_segment.len();
    Ok((&rest[consumed_len..], ()))
References
  1. Avoid verbatim string equality for parsing Oracle phrases; instead, decompose compound phrases into modular, reusable parsers and compose them using idiomatic combinator aggregates like nested alt and tag sequences.

Comment on lines 1667 to 1669
pub(super) fn rewrite_two_target_counter_chain(def: &mut AbilityDefinition) {
if count_typed_target_only_slots(def) >= 2 {
rekey_counter_slot_in_chain(def);

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.

medium

[MED] Rename functions to reflect generalized buff rekeying. Evidence: crates/engine/src/parser/oracle_effect/lower.rs:1667-1669.
Why it matters: The functions rewrite_two_target_counter_chain and rekey_counter_slot_in_chain now handle Pump and GenericEffect in addition to counters, making the "counter" naming misleading and harder to maintain. Suggested fix: Rename them to rewrite_two_target_buff_chain and rekey_buff_slot_in_chain.

Suggested change
pub(super) fn rewrite_two_target_counter_chain(def: &mut AbilityDefinition) {
if count_typed_target_only_slots(def) >= 2 {
rekey_counter_slot_in_chain(def);
pub(super) fn rewrite_two_target_buff_chain(def: &mut AbilityDefinition) {
if count_typed_target_only_slots(def) >= 2 {
rekey_buff_slot_in_chain(def);

@@ -1684,15 +1688,46 @@ fn count_typed_target_only_slots(def: &AbilityDefinition) -> usize {
}

fn rekey_counter_slot_in_chain(def: &mut AbilityDefinition) {

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.

medium

[MED] Rename helper function to reflect generalized buff rekeying. Evidence: crates/engine/src/parser/oracle_effect/lower.rs:1690.
Why it matters: The helper function now handles Pump and GenericEffect in addition to counters, so renaming it to rekey_buff_slot_in_chain improves code readability and maintainability. Suggested fix: Rename the function to rekey_buff_slot_in_chain.

Suggested change
fn rekey_counter_slot_in_chain(def: &mut AbilityDefinition) {
fn rekey_buff_slot_in_chain(def: &mut AbilityDefinition) {

@matthewevans matthewevans added the bug Bug fix label Jul 11, 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.

Request changes — the sentence-bounding fix (part 1) is right and I want it; the buff rekey (part 2) widens a rewrite that runs over the whole card pool behind a guard that never checks for a fight.

Thanks for this — the root-cause analysis is genuinely good, and it's a strong first contribution. I verified your claims rather than taking them on faith, and most of them hold up.

🔴 Blocker — the rekey's guard doesn't encode the class it documents

rekey_counter_slot_in_chain (crates/engine/src/parser/oracle_effect/lower.rs:1686-1713) recurses into sub_ability and else_ability, rewriting every matching node. It runs from the generic assembly pipeline (crates/engine/src/parser/oracle_effect/assembly.rs:1860), so it is applied to every ability chain in the pool. Its only guard is count_typed_target_only_slots(def) >= 2 (lower.rs:1664).

That guard never checks for a fight. The function is named rewrite_two_target_counter_chain and its doc comment describes "the two-target fight class" — but what it actually tests is "this chain has ≥2 typed target slots." Those are not the same set. While the matcher was PutCounter{ParentTarget}-only, the gap was masked (measured: exactly 3 cards). Widening it to Pump{Any|ParentTarget} unmasks it.

Consequence: in any ≥2-target chain, every Pump{Any|ParentTarget} node in the chain is rewritten to ParentTargetSlot { index: 0 } — including chains with more than one pump, which then all collapse onto the same slot.

The full pool (data/card-data.json, 35,396 faces) has 45 faces with ≥2 target and ≥2 gets +/-. The ones structurally at risk:

Card Oracle text If the pumps lower to Any/ParentTarget
Consume Strength "Target creature gets +2/+2… Another target creature gets -2/-2…" both the buff and the debuff land on slot 0 — same creature, net zero, opponent untouched
Leeching Bite "+1/+1 … Another target creature gets -1/-1" same collapse
Drooling Groodion "+2/+2 … Another target creature gets -2/-2" same collapse
Gloom Ripper "target creature you control gets +X/+0 … up to one target creature an opponent controls gets -0/-X" the debuff lands on your own creature
Gurmag Rakshasa "target creature an opponent controls gets -2/-2 … and target creature you control gets…" same, inverted
Arm the Cathars "+3/+3, up to one other target creature gets +2/+2, and up to one other … +1/+1" all three buffs collapse onto one creature
Bounty of Might three × "Target creature gets +3/+3" all three onto one creature

To be precise about what I have and have not established. Confirmed by reading the source: the rewrite is pool-wide, it recurses over the whole chain, it rewrites every matching node to index 0, and the guard is ≥2-slots-only. Not confirmed: whether these specific cards' pumps actually lower to Any/ParentTarget rather than an explicit Typed filter — if they all lower to Typed, the guard never fires and they're safe. That is exactly the claim your comment makes ("Only the two-target fight chain routes a Pump through here… an unscoped Any/ParentTarget target is always that back-reference"), and it is an empirical claim about 35,396 faces that has been checked against 6.

I'm blocking on the evidence standard rather than a proven break, because this failure mode is invisible to every gate we have: a pump bound to the wrong slot emits no Effect::Unimplemented, produces no coverage delta, and — I checked — no test in the repo covers Consume Strength, Leeching Bite, Drooling Groodion, Gloom Ripper, or Arm the Cathars. It would land silently and we'd find out from a player.

Fix — make the guard mean what the doc comment says. Gate the rewrite on the class's actual signature: the presence of an Effect::Fight node (crates/engine/src/types/ability.rs:9737) in the chain, not on "has ≥2 target slots." That is the real categorical boundary, it excludes every card in the table above by construction, and it makes the widened matcher safe without an empirical argument. Since the class has exactly one buff, additionally asserting a single buff node would foreclose the multi-pump collapse entirely.

If you'd rather keep the slot-count guard, then the ask is a full-pool dual-run (all 35,396 faces in data/card-data.json, not the ~2,410-face fixture) enumerating every face that reaches the Pump{Any|ParentTarget} + ≥2-slot path, with confirmation that each is genuinely the fight-class back-reference. Note the fixture corpus is ~7% of the pool, so a green fixture run is not evidence here.

🟡 Non-blocking

  • CI on this head proves nothing yet. As a first-time contributor your workflows were gated: only Apply AI-CONTRIBUTOR triage labels, Contributor trust (skipping), and Superagent Security Scan ran. Clippy, test-engine, card-data, coverage, and the parse-diff sticky never executed, so the "green" is an artifact of "nothing that ran, failed." I've approved the runs — real CI will now execute, and the <!-- coverage-parse-diff --> sticky it produces is required review evidence for a parser change like this. Please confront that card-level diff against your claimed scope when it appears.
  • The PR description is missing the AI-CONTRIBUTOR template sections (summary, files changed, track, llm). Please see docs/AI-CONTRIBUTOR.md and fill them in — they're the gates we review against.
  • CR 611.2c is correctly cited for the pump (a resolution-generated continuous effect's affected set is fixed when it begins), but the sentence in your description attaches it to "those creatures fight each other," which is CR 701.14 (Fight), not a continuous effect. The in-code annotations are fine; it's just the prose.

✅ Clean — verified, not assumed

  • The sentence-bounding fix is correct and I want it. Bounding the verb scan to before the first ". " so a later sentence's gets/gains/has/loses can't be pulled back onto a two-target declaration is the right seam, and the root-cause write-up is accurate.
  • Your "the boolean caller ignores the remainder" claim checks out. I traced it: the sole call site is sequence.rs:2709, inside a chain terminating in .parse(s).is_ok() at sequence.rs:2763-2764. The remainder is genuinely discarded, so returning rest rather than the post-verb remainder is safe. Thank you for flagging it explicitly instead of leaving it for a reviewer to discover.
  • Both CR citations grep-verified against docs/MagicCompRules.txt. CR 601.2c really is the multi-instance-of-"target" rule (line 2461) and CR 611.2c really is the continuous-effect affected-set rule (line 2909). Correctly chosen, not decorative — that's rarer than it should be.
  • Composed from existing combinators (take_until/alt/value), no new enum variants, no verbatim Oracle-text matching.

Recommendation: re-key the guard on Effect::Fight (or bring the full-pool measurement), push, and let the now-unblocked CI run. Part 1 lands as-is. I'll re-review on the new head.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 7 card(s), 6 signature(s) (baseline: main 66dbe0c9aeef)

5 card(s) · ability/target · removed: target

Examples: Blizzard Brawl, Hog-Monkey Rampage, Joust (+2 more)

4 card(s) · ability/TargetOnly · added: TargetOnly (target=opponent controls creature)

Examples: Blizzard Brawl, Hog-Monkey Rampage, Joust (+1 more)

1 card(s) · ability/Choose · added: Choose (choice=number (0-20))

Examples: Life at Stake

1 card(s) · ability/PutCounter · field target: parent target slot 0parent target

Examples: Strength of the Tajuru

1 card(s) · ability/you · added: you

Examples: Mages' Contest

1 card(s) · ability/you · removed: you

Examples: Life at Stake

@matthewevans

Copy link
Copy Markdown
Member

Correction — I withdraw the main blocker. The parse-diff I asked for arrived, and it refutes my claim. What's left is much smaller, but one item is real and I'd like it resolved before merge.

⬜ Withdrawn: the pump-collapse collateral

I claimed that widening the matcher to Pump{Any|ParentTarget} would collapse targets across ~45 at-risk faces, and named Consume Strength, Leeching Bite, Gloom Ripper and Arm the Cathars. That did not happen. The parse-diff (baseline b92147426c95) shows 6 cards, 5 signatures pool-wide, and not one of them is a pump card. The only ability/target changes are Blizzard Brawl, Hog-Monkey Rampage, Joust and two more — exactly the fight class you're targeting.

I flagged that as an evidence-standard block, not a proven break, and said explicitly that I hadn't confirmed whether those pumps lower to Any/ParentTarget or to Typed. The evidence resolved it against me: they lower to Typed, so they never match your matcher. Your change is tighter than I gave it credit for, and I should have confirmed that before writing the number down. Sorry for the round-trip.

🔴 Blocker (the real one, and it's narrow)

Two cards moved in opposite directions on the same construction. From the parse-diff:

  • Life at Stakeyou removed, Choose (choice=number (0-20)) added
  • Mages' Contestyou added

These are the same grammatical shape:

Life at Stake: "You and target creature's controller each secretly choose a number 0 or greater."
Mages' Contest: "You and target spell's controller bid life."

Both are You and target <X>'s controller … — a coordinated two-subject clause. A correct change moves that class coherently; yours moves one card into you and the other out of it. They can't both be right, which means part 1's sentence-bounding is keying on something other than the construction.

The direction that worries me is Life at Stake losing you. The card requires both players to choose a number — that's the whole card. If you drops out of the parsed ability, the controller stops being a chooser and the card is simply broken. (Mages' Contest gaining you may well be a genuine improvement — but I can't credit it as one while its twin moves the other way.)

Neither card is in your claimed scope, so these are collateral from part 1 (bounding the verb scan to the current sentence), not from the rekey.

What I need: account for both. If Life at Stake losing you is a regression, fix it. If both changes are actually correct, explain why the same construction resolves differently and add a test pinning it — I'll take a good explanation over a code change.

🟡 Non-blocking, but I'd still take the one-line fix

rekey_counter_slot_in_chain is named for "the two-target fight class" and its doc comment says so, but its guard (lower.rs:1664) only checks count_typed_target_only_slots >= 2 — it never checks for a fight. The parse-diff shows this is empirically safe today, and I accept that. But it's safe because real 2-target pump cards happen to use Typed slots, so they miss your matcher by luck of the pool. That's accidental safety, not structural safety: the next parser change that lets an unscoped pump through re-opens it silently, and the function's own name will tell the next reader it can't happen.

Gating on Effect::Fight (types/ability.rs:9737) makes the guard encode the class it claims. It's cheap now and it's the difference between "the types prevent this" and "the current card pool doesn't happen to trigger it."

✅ Credit where it's due

Part 1 — bounding the verb scan to the current sentence — is the right seam, and your claim that the caller ignores the remainder checks out: the sole call site (sequence.rs:2709) terminates in .parse(s).is_ok() at 2763-64. Both CR citations grep-verify (601.2c, 611.2c). And the parse-diff confirms the fight-class change lands exactly where you said it would, with no collateral — which is what a well-scoped parser fix looks like.

Recommendation: resolve the Life at Stake / Mages' Contest divergence and I'll approve. The fight-gate is a request, not a gate.

…e at Stake

Review follow-up (phase-rs#4751, matthewevans):

- Gate rewrite_two_target_counter_chain on the class's real signature —
  >= 2 typed target slots AND an Effect::Fight in the chain (chain_contains_fight),
  CR 701.14a. The slot count alone said nothing about a fight; requiring the
  Fight node makes the widened Pump/GenericEffect rekey safe by construction
  rather than by the pool happening not to trip it. All six class cards
  (Malamet, Longstalk, Duel, Tail Swipe, Joust, Blizzard Brawl) still rekey to
  slot 0.

- Pin Life at Stake: the review's collateral 'you removed' is a stray
  Unimplemented("you") head dropping, not a lost chooser. The functional
  Choose{NumberRange} + exile + lose-life chain is byte-identical to main; the
  card is unimplemented on both sides. Test asserts the chain heads at the
  NumberRange Choose, keeps the exile/lose-life tail, and no longer carries a
  stray Unimplemented("you").
@jaytbarimbao-collab

Copy link
Copy Markdown
Contributor Author

Thanks — and thanks for the correction round; the parse-diff resolving the collapse claim was exactly the right call. On the remaining item, I dug into both cards and I think the divergence is benign. Evidence, then a pinning test.

Life at Stake is not regressed — the you was a stray Unimplemented stub

The you the diff removed was never a functional chooser. Full ASTs, main vs this branch:

main:

Unimplemented { name: "you", description: "You" }        ← stray fragment
  └ Choose { NumberRange { 0..=20 } }
      └ NoOp → ChangeZone{Exile, ParentTarget} → LoseLife{ All }

this branch:

Choose { NumberRange { 0..=20 } }
  └ NoOp → ChangeZone{Exile, ParentTarget} → LoseLife{ All }

The actual mechanic — Choose { NumberRange } → exile → lose life — is byte-identical. The only delta is the leading Unimplemented { name: "you", description: "You" } head, which is a no-op coverage-gap sentinel, not a chooser. On main the bare "You" got split off its "and" by a coincidental loses in the LATER sentence ("Each player who chose the highest number loses that much life"); bounding the verb scan to the first sentence stops that coincidental split, so the chain now heads at the real Choose node.

So the controller doesn't "stop being a chooser" — the card never modeled both players choosing on either side (the Choose node carries no per-player chooser; that's a pre-existing gap, orthogonal to this PR). This branch drops one meaningless Unimplemented fragment and keeps the mechanic intact.

Why the two cards move in opposite directions

They don't diverge functionally — both are unimplemented on main and on this branch (Mages' Contest is a chain of Unimplemented + a trailing Counter on both). What moved is only how each card's unimplemented head chunks:

  • Life at Stake: main split "You" into its own Unimplemented("you"); the fix stops the coincidental split, so "you" no longer appears as a standalone node → diff reads removed.
  • Mages' Contest: main split "You" and "target spell's controller bid life" into two Unimplemented nodes; the fix keeps the first sentence as one Unimplemented("you", "You and target spell's controller bid life") → the aggregated signature reads added.

The construction resolves the same way on both (unimplemented); the sign of the you-node signature delta differs only because the two unimplemented heads chunk differently. No functional chooser is created or destroyed on either card. The direction that worried you — Life at Stake losing a chooser — isn't reachable, because there was no parsed chooser to lose.

Pinning test added: life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub asserts the chain heads at the NumberRange Choose, keeps the exile + lose-life tail, and no longer carries a stray Unimplemented("you") head.

Fight-gate — taken (part 2 now encodes the class)

Re-keyed the guard on the class's real signature: count_typed_target_only_slots(def) >= 2 && chain_contains_fight(def) (a linear Effect::Fight descent). The slot count alone said nothing about a fight; requiring the Fight node makes the widened Pump/GenericEffect rekey safe by construction, not by the pool happening not to trip it. Verified all six class cards (Malamet, Longstalk, Duel, Tail Swipe, Joust, Blizzard Brawl) still rekey the buff to slot 0.

Also fixed the description prose (CR 701.14 for the fight, CR 611.2c only for the pump) and filled in the AI-CONTRIBUTOR template sections.

@matthewevans

Copy link
Copy Markdown
Member

Blocker withdrawn — I verified your refutation independently, and you're right. Approving on settled green; nothing further needed from you.

⬜ Withdrawn: the Life at Stake regression

I claimed Life at Stake was losing a chooser, and that the card would simply be broken. It isn't, and I checked rather than taking your word for it. From main's own generated client/public/card-data.json:

"abilities": [{
  "kind": "Spell",
  "effect": { "type": "Unimplemented", "name": "you", "description": "You" },
  "sub_ability": {
    "effect": { "type": "Choose", "choice_type": { "NumberRange": { "min": 0, "max": 20 } } },
    ...
  }
}]

The head node on main is the stray Unimplemented("you") stub, exactly as you said, with the real Choose { NumberRange } sitting underneath it as the sub-ability. It was never a chooser — it's a no-op coverage-gap sentinel that got split off its "and" by a coincidental loses in the later sentence. Bounding the verb scan stops that split, the chain re-heads at the real Choose, and the mechanic underneath is byte-identical.

So the divergence you explained is exactly what you said it is: both cards are unimplemented on both sides, and the sign of the you-signature delta differs only because the two unimplemented heads chunk differently. No functional chooser is created or destroyed on either card. The direction that worried me isn't reachable, because there was no parsed chooser to lose.

That's the second claim of mine this PR has refuted with evidence. Both times you brought the AST instead of an argument, which is the right way to move a reviewer — and I've retracted the corresponding signal from your record.

✅ The fight-gate — taken, and it's the structural version

if count_typed_target_only_slots(def) >= 2 && chain_contains_fight(def) {

with chain_contains_fight descending sub_ability/else_ability. That's the difference I was after: the guard now encodes the class its own name claims, rather than being safe because the current card pool happens not to trip it. CR 701.14a grep-verifies (docs/MagicCompRules.txt:3384 — "it may instruct two creatures to fight each other"), and so does CR 611.2c for the pump.

✅ Tests drive the real entry point

Both new tests call parse_oracle_text(...) — the entry database::synthesis actually uses — not parse_effect_chain with a fresh context. That matters more than it might look: a test built on the standalone chain entry can pass on a path the card never walks, and we've had exactly that failure land recently. Yours exercise the pipeline the card really goes through, and both would fail on revert.

Next step

Nothing on you. CI is still in flight on a4583f01 (8 checks pending, 0 failures) and the branch is BEHIND; I don't approve on a green I haven't watched. I'll approve, label, and enqueue on settled green — the merge queue resolves the BEHIND.

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

Request changes — CI is terminal red on a pre-existing class guard that this PR overturns without reconciling it. The parser work itself still looks right to me, and my earlier blocker on Life at Stake stays retracted. The problem is narrower and load-bearing: #5482 pinned an invariant about this exact card, and this PR breaks it silently.

🔴 Blocker — the PR reverses a pinned exclusion from #5482, and the guard is now red

crates/engine/tests/integration/s07_malamet_castpath.rs:320tail_swipe_class_guards_do_not_touch_it_and_stays_red FAILED on this head (Rust tests (shard 1/2), run 29183253831):

Tail Swipe has <2 Typed TargetOnly slots (guard excludes it)

That test is not yours and it is not new. It landed on main in #5482 (9d82a1d0cc, merged 2026-07-10T07:47Z) — a day before this PR was opened (2026-07-11T15:34Z) and two days before the current head (2026-07-12T06:49Z). So this is not main drift and not a rebase artifact: the guard was already on main when you branched.

Its doc comment states the invariant in as many words:

/// Tail Swipe stays RED — the class-shape guards (c)/(d) must NOT touch it. Its
/// non-counter Pumpnode is not rekeyed toParentTargetSlot, gets no
/// subject_slot, and it keeps an Unimplemented node (its unparsed main-phase
/// pump gate keeps gap>0).

and it asserts the exclusion witness directly:

// Exclusion witness: only 1 TargetOnly slot (+ an Unimplemented for the second
// target), so the >=2-TargetOnly rewrite guard never fires.
assert!(target_only < 2, "Tail Swipe has <2 Typed TargetOnly slots (guard excludes it)");

Your change makes Tail Swipe parse both targets as typed TargetOnly slots. Combined with chain_contains_fight — unambiguously true for Tail Swipe ("Then those creatures fight each other.") — the card now satisfies count_typed_target_only_slots(def) >= 2 && chain_contains_fight(def) and enters the very rewrite class #5482 wrote a witness to keep it out of. #5482 also deliberately scoped the rekey to PutCounter only (the guard's own words: "Pump must NOT be rekeyed to ParentTargetSlot (PutCounter-only guard)"); this PR widens it to Pump/GenericEffect.

To be clear: you may well be right. #5482's exclusion reads like a "we cannot handle this yet" placeholder, not a permanent invariant — and bringing Tail Swipe into the class is exactly what this PR is for. But overturning a two-day-old pinned invariant is a design change that has to be argued, not landed silently through a red test.

🔴 Blocker — unresolved wrong-game-result risk: does the main-phase gate survive?

Tail Swipe, verbatim from Scryfall (verified, not paraphrased):

Choose target creature you control and target creature you don't control. If you cast this spell during your main phase, the creature you control gets +1/+1 until end of turn. Then those creatures fight each other.

#5482's exclusion rested on that cast-timing clause staying Unimplemented"its unparsed main-phase pump gate keeps gap>0" — which is what kept the card red and therefore unplayable. If your change makes the two targets parse while that condition is still dropped, Tail Swipe becomes more supported with an unconditional pump: it would get +1/+1 even when cast outside your main phase. That is a wrong game result, and it is precisely the failure #5482's witness was standing in front of.

The guard panicked on its first assertion, so CI never reached the Pump-rekey and subject_slot assertions — which means neither of us can currently see whether the condition survives.

Please show, with a test: does Effect::Pump on Tail Swipe still carry the main-phase cast condition after the rekey, or is it now unconditional? If the condition is preserved and the card is genuinely correct, say so and update #5482's witness with that reasoning. If it isn't, the gate needs to keep Tail Swipe out until the cast-timing clause parses.

✅ Clean — credited, and unchanged from my last pass

  • You refuted my earlier blocker with evidence and you were right. I claimed Life at Stake would lose a chooser. I verified against main's generated card-data.json: the head node there is a stray Unimplemented stub with the real Choose underneath, so there was never a parsed chooser to lose. My finding is withdrawn; nothing about it is on you.
  • chain_contains_fight is the right instinct — you saw that widening the rekey to Pump alone would be too broad and bounded it deliberately. That reasoning is visible in the comments and it is correct as far as it goes; it just doesn't yet account for Tail Swipe itself.
  • CR 701.14a (fight) and CR 611.2c grep-verify and govern.
  • Both new tests drive parse_oracle_text — the entry database::synthesis actually uses — rather than parse_effect_chain with a fresh context. That is the path the card really walks, and it is the right choice.

Recommendation

Request changes. Two sides deliberately modified the same class-shape seam: #5482 contributes "Tail Swipe stays excluded and red", this PR contributes "the two-target fight class rekeys the buff." Don't resolve that by deleting the guard — reconcile it, and land a discriminating test from each side green on the same head:

  1. Decide and state whether Tail Swipe belongs in the class. If yes, update tail_swipe_class_guards_do_not_touch_it_and_stays_red with the justification (it is fine to change a witness; it is not fine to leave it red).
  2. Prove the main-phase cast condition is preserved on the rekeyed Pump — or keep Tail Swipe excluded until it parses.
  3. Keep Malamet's existing behavior green.

The parse-diff sticky is still baseline_pending, so I have no card-level blast radius yet either; once CI is green it should render and I'll confront it against your claimed scope. Ping me when the head moves — no defect signal recorded for this round.

…een)

main's s07_malamet_castpath::tail_swipe_class_guards_do_not_touch_it_and_stays_red
pinned Tail Swipe as a known gap (< 2 target slots, Pump un-rekeyed, keeps an
Unimplemented node). This PR closes that gap, so the pin failed on the merged
head. Flip it to tail_swipe_class_keeps_both_targets_and_buffs_slot_zero: both
target slots survive, the Pump is rekeyed to ParentTargetSlot { index: 0 }, and
no Unimplemented node remains. Verified on the merged code alongside the other
five class cards.
@jaytbarimbao-collab

Copy link
Copy Markdown
Contributor Author

Merged current main in (clean, no conflicts) to bring the branch up to date. One favorable seam conflict surfaced and is resolved:

main added s07_malamet_castpath::tail_swipe_class_guards_do_not_touch_it_and_stays_red, which pinned Tail Swipe as a known gap< 2 target slots, Pump not rekeyed, keeps an Unimplemented node ("stays RED"). This PR closes exactly that gap, so the test correctly failed on the merged head. I flipped it to tail_swipe_class_keeps_both_targets_and_buffs_slot_zero, asserting the now-fixed behavior (2 target slots, PumpParentTargetSlot { index: 0 }, no Unimplemented).

Re-verified on the merged code: all six class cards keep both target slots and rekey the buff to slot 0 (unimpl=false for every one, Tail Swipe included). The other s07 runtime guards (Malamet / Longstalk / Duel / Time to Feed) are untouched.

@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. Both blockers resolved, and one of them was mine — the "unconditional pump" risk I raised does not exist, and I verified that rather than taking your word for it.

✅ Blocker 2 (the cast-timing condition) — refuted, by construction

I was worried that once the second target parsed, Tail Swipe's Unimplemented node would vanish and take the main-phase gate with it, leaving an unconditional +1/+1. That premise was wrong, and main's own generated card-data.json shows why:

- TargetOnly (creature you control)
  - Unimplemented  name='target'   ← this is the SECOND TARGET, not the cast clause
    - Pump   cond={"type":"CastDuringPhase","phases":["PreCombatMain","PostCombatMain"]}
      - Fight

CastDuringPhase already parses on main and lives on the Pump node independently of the Unimplemented one. The two were never coupled.

And the condition survives your rewrite mechanically, via two independent guarantees:

  1. rekey_counter_slot_in_chain assigns only *target (lower.rs) — it mutates in place and never reconstructs the node, so condition is untouched.
  2. The condition tail fires only for AbilityCondition::TargetMatchesFilter { subject_slot }. Tail Swipe's is CastDuringPhase, so the if let doesn't match and it's left alone.

The pump stays conditional. Blocker cleared.

✅ Blocker 1 (the pre-existing guard) — legitimately closed, not weakened

You inverted tail_swipe_class_guards_do_not_touch_it_and_stays_red into tail_swipe_class_keeps_both_targets_and_buffs_slot_zero. Rewriting a guard that just caught you is exactly the move that deserves scrutiny, so I checked it — and it's correct. #5482 pinned Tail Swipe as RED to record a known gap, not to assert a safety invariant. This PR closes that gap, so inverting the witness is the honest response, and the new assertions are strictly stronger.

✅ The fight gate — and a card you fixed without knowing it

Adding chain_contains_fight(def) to the gate is the fix I asked for, and it did more than bound future collateral. The parse-diff shows:

1 card · ability/PutCounter · target: `parent target slot 0` → `parent target`
Examples: Strength of the Tajuru

That reads like a regression. It isn't — it's a repair. Strength of the Tajuru is "Choose target creature, then choose another target creature for each time this spell was kicked. Put X +1/+1 counters on each of them." It has no fight, and it was only being rekeyed because the old gate matched on slot-count alone. And:

// filter.rs:3354
TargetFilter::ParentTarget          => ability.targets.iter().collect(),        // ALL targets
TargetFilter::ParentTargetSlot{i}   => ability.targets.get(*i).into_iter()...   // ONE target

So on main, a multikicked Strength of the Tajuru put counters on only the first creature. Evicting it from a class it never belonged to restores ParentTarget = all targets — which is what "each of them" means. Narrowing the guard undid collateral that had already shipped.

Blast radius — bounded by hand

The >= 2 typed slots AND Fight gate holds a closed population. On main exactly 3 cards qualify (Duel for Dominance, Longstalk Brawl, Malamet Battle Glyph — all already rekeyed). Your sentence-bounding fix admits 3 more (Tail Swipe, Joust, Blizzard Brawl) by making their second target parse, for the 6 class cards you name. Nothing else can enter without declaring two typed targets and containing an Effect::Fight.

Parse-diff: 7 cards / 6 signatures against a fresh baseline (66dbe0c9ae), every line accounted for. All 12 checks green, including Card data (10m21s).

Merging. No quality label — this took several heads — but the pushback on my blocker was evidence-backed and right, for the second time this week. That is the correct way to handle a review you disagree with.

@matthewevans
matthewevans added this pull request to the merge queue Jul 12, 2026
Merged via the queue into phase-rs:main with commit 5d248ff Jul 12, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tail Swipe — Tail Swipe half works.

2 participants