Skip to content

feat(parser): L02 condition lane — typed conditions on ~30 Standard cards (BB1–BB8 + BB-FU1–5)#5677

Merged
matthewevans merged 41 commits into
phase-rs:mainfrom
lgray:feat/std-l02-condition
Jul 13, 2026
Merged

feat(parser): L02 condition lane — typed conditions on ~30 Standard cards (BB1–BB8 + BB-FU1–5)#5677
matthewevans merged 41 commits into
phase-rs:mainfrom
lgray:feat/std-l02-condition

Conversation

@lgray

@lgray lgray commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

L02 "condition lane": clears the SwallowedClause{Condition_*} parse warnings on ~30 Standard target cards by attaching their typed conditions to the correct ability/trigger/replacement layer, built as 8 reusable building blocks (BB1–BB8) plus 5 in-branch follow-ups (BB-FU1–5) that harden the shared condition/quantity infrastructure. Net +34 newly-supported cards vs the #5668 base with zero engine regressions (coverage-regression gate: engine/oracle-changed 0); swallowed-clause diagnostics drop 1025→986. This revision additionally enforces the CR 122.1h finality death→exile replacement — 3 commits (typed CounterType::Finality → the ZoneChange{Battlefield→Graveyard}→Exile intrinsic replacement → a 4-path discriminating test suite) that make BB3's previously placement-only finality counters actually redirect a dying permanent to exile, resolving the maintainer's remaining blocker.

Parse-change population (69 cards / 66 signatures): because the building blocks operate on card classes, the head-current parse-diff touches more than the ~30 named targets. The full population is enumerated + classified below — all are intended targets or correct same-signature class-collateral; every */REMOVED pairs with an upgraded typed replacement (no capability loss).

Parse-change population appendix (categorized, vs base fa7f157fa)

Class Count What
(a) Intended targets ~29 The named BB1–BB8 + BB-FU cards.
(b) Correct class-collateral 37 28 "entered this turn" live-board→BattlefieldEntriesThisTurn ledger (BB-FU1, CR 608.2i — departure-surviving look-back); 9 "make a token, put a counter on it" anaphors now binding to the token (BB-FU5); + Wilt in the Heat. Each an Oracle-verified same-typed-signature repair.
(a/repr) Representation-only 3 ObjectCount zone made explicit (# of X# of in-battlefield X); no behavior change.

Capability-removal clarifications (reviewer-flagged, measured): Intrepid Paleontologist + Leonardo lost a garbage self-ETB Moved misparse (measured inert: counter on the wrong object, die→graveyard not exile) → replaced by correct Exile/GraveyardCastPermission{finality}. Blasphemous Edict's ability/PayCost (a misparsed "pay {B} rather than mana cost") → correct AlternativeCost casting option (BB7, tested). Finality die→exile enforcement (CR 122.1h) is now implemented in this branch (the 3 finality commits below), superseding BB3's original placement-only ceiling.

Verify-flagged, measured correct/deferred: Lupine Prototype + Naktamun Lorespinner ("a player has ≤N cards") — measured proper existential-over-all-players (the HandSize player field is an inert placeholder read per-candidate). Happily Ever After — a pre-existing there sentence-fragment misparse (coverage-unsupported, excluded from real play); tracked as a held follow-up, not introduced by this lane.

Every unit went through /engine-implementer (trace → plan → /review-engine-plan → executor → /review-impl), was verified with a discriminating revert-probe, and was ratified at its commit boundary.

Deferred follow-ups (surfaced by this series, tracked, gated on the user)

These were discovered during L02 and intentionally scoped OUT as standalone follow-ups (novel-vs-inherited disposition; none block the L02 deliverable):

  • BB-FU6 — execute-condition-path triggers lack a CR 603.4 fire-time intervening-if re-check (codebase-wide).
  • BB-FU7 — parser over-lowers non-goad "attacks if able" → Effect::GoadAll (spurious goaded_by).
  • BB-FU8 — "milled-this-way shared-quality + conditional-repeat" feature (Tamiyo + Grindstone).
  • BB-FU9 — reflexive "When you do" trigger body doesn't seed token_created_in_chain (Iroh class).
  • BB-FU10 — parse_entered_this_turn_ref live-board→snapshot for the optional-controller any-player class (sibling of BB-FU1).
  • BB-FU11 — batched counter-trigger per-kind reproduction aggregation (the 3 KIND cards BB-FU2 kept batched:false).
  • BB-FU12 — parse granted "first time counters this turn" condition (Danny Pink class).
  • mana_spent_to_cast_amount not scrubbed + reset-hygiene + ReplacementCondition::Or (Freestrider / Lavinia / Satoru).
  • CR-annotation sweep: 110.5b110.5 + 117.1c cite imprecision.
  • Ebondeath Dracolich name-negation parser (cast-permission subject).

(Formerly listed here: finality-counter death→exile CR 122.1h enforcement — now implemented in this branch, see the finality commits.)

Known ceilings (intentional, documented)

  • CR-cite imprecision (110.5b, 117.1c) is deferred to the standalone CR-annotation sweep rather than touched piecemeal here.
  • BB-FU2 per-kind reproduction: the 3 "same number and kind" reproduction cards (Bold Plagiarist / Aragorn / Captain Marvel) remain batched:false, gated on Effect::Unimplemented, pending the typed reproduction effect (BB-FU11).

Implementation method (required)

Method: /engine-implementer

CR references

Primary rules implemented/annotated across the branch:

  • Conditions & triggers: CR 603.4 (intervening-if), CR 603.2c (batched triggers), CR 603.6c + CR 603.10a (leaves-battlefield look-back), CR 201.5 (self-reference → source), CR 608.2 / 608.2c / 608.2h / 608.2i (information look-back)
  • Casting & permissions: CR 601.1 / 601.2a / 601.2f / 601.2h / 601.3 (cast process), CR 602.5 (activate-only-if), CR 116.2a + CR 305.1 (land special action), CR 707.10 (copy is not cast)
  • Counters & continuous: CR 122 / 122.1 / 122.1h (counters), CR 611.2a / 611.2b / 611.3a (continuous effects), CR 400.7 (object identity)
  • Finality death→exile enforcement (this revision): CR 122.1h (the finality replacement, verbatim) + CR 614.6 (modified event occurs instead) + CR 616.1 / 616.1e (replacement ordering) + CR 700.4 (dies = to graveyard from battlefield) + CR 400.7 (exiled permanent is a new object → counters cease). Contrast annotated against shield CR 122.1c (which removes a counter; finality does not).
  • Full per-commit CR annotations are in each commit body (all grep-verified against docs/MagicCompRules.txt). Notable correction: BB-FU3 fixes a BB4-introduced miscite on the dying gate (CR 603.6a[ETB] → CR 603.6c + 201.5 + 603.10a + 603.4).

Verification

  • Required checks ran clean (full CI-equiv on the committed head, exact CI invocations).
  • 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.

Full CI-equiv (worktree cargo, exact CI commands) — all GREEN at committed tip 5f7acb9 (the 3 finality commits on top of the maintainer's merge c49e6a8; base b2a827081, upstream/main 5e7b786):

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings — clean (0 warnings)
  • ./scripts/check-parser-combinators.sh <base> — Gate A PASS (finality adds zero production parser lines)
  • ./scripts/check-skill-doc.sh — ✓ · ./scripts/check-engine-authorities.sh <base> — Gate B PASS (82 zone hits / 62 rows; draw-producers 7 rows) · ./scripts/check-test-card-data-load.sh <base> — ✓
  • card-data-validate -- data/card-data.json — OK (35396 parsed); oracle-gen 92.4% implemented — identical to the pre-finality run (typed CounterType::Finality serializes byte-identically to "finality" → parse-neutral, no card-data migration)
  • python3 scripts/draw_replacement_census.py --corpus --check — PASS (51 rows)
  • coverage-report -- data/ --all + coverage-regression-check.sh <R2> --fail-on-engineREGRESSED(engine)=0, ORACLE CHANGED=0; swallowed-clause 1126→1069. The 3 REGRESSED(honesty) cards (Avatar Destiny / Machinist's Arsenal [Swallow:DynamicQty], The Doctor's Childhood Barn [Effect:when]) are stale-base false positives — the branch is 6 commits behind upstream/main incl. #5689 (parser "for each" scaling fix, supports the two DynamicQty cards) and #5688; the finality diff has zero production parser edits, so it cannot affect them. They clear on merge-forward.
  • cargo nextest run --profile ci --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest (PROPTEST_CASES=32) — 21051 passed / 21 skipped / 0 failed, incl. the 4 new finality death→exile tests (destroy / sacrifice / SBA-lethal / SBA-0-toughness)
  • Finality revert-probe (non-vacuity): neutralizing the replacement's virtual-candidate injection arm flips all 4 positive assertions Exile→Graveyard (measured FAIL → restore → PASS).

(The L02 parse-population figures above — 69/66, +34, 1025→986 — were measured vs the #5668 base fa7f157fa before the maintainer merged main forward; the finality work is parse-neutral so the population is unchanged, but the absolute coverage counts shifted with main's own swallow-audit evolution fa7f157fa→b2a827081. Population re-measure vs the moved base is a reconciliation item.)

Gate A

Gate A PASS head=5f7acb903af86b96045dd2792d9cf6c33edee6d5 base=5e7b786704d1d8413d33a6f6ed32f2873824e316

Anchored on

  • crates/engine/src/parser/oracle_nom/condition.rs — parse_inner_condition, the single authority for game-state condition recognition that every BB delegates to (no bespoke string matching)
  • crates/engine/src/parser/oracle_trigger.rs — the intervening-if extraction chain (extract_if_condition_with_card_name → zone-change filter dispatch) that BB4/BB-FU3 extend structurally

Final review-impl

Final review-impl PASS head=5f7acb903af86b96045dd2792d9cf6c33edee6d5

Claimed parse impact

L02 target cards (SwallowedClause cleared): the 30 Standard condition-lane cards across BB1–BB8 (activate-only-if, cast-origin, as-long-as, finality/exile cast-permission, conditional enters-with-counter, cast-context cost gates, cast-this-way/goad). Follow-up-touched: Gargoyle Flock (BB-FU1), Stalwart Successor / All Will Be One / Generous Patron / Kros / Putrid Hexhag / Rikku / Danny Pink (BB-FU2, batched-counter flag), Phage the Untouchable (BB-FU4 fence), Otterball / Match-the-Odds class (BB-FU5). BB-FU3 is representation-stable (byte-identical card-data; the fix is structural disambiguation proven by a synthetic production-path test).

@lgray
lgray requested a review from matthewevans as a code owner July 12, 2026 13:09

@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 implements comprehensive rules support and parser updates for several MTG mechanics, focusing on cast-origin conditions, enters-with-counter riders on static cast permissions, and intervening-if conditions. Key changes include renaming AbilityCondition::CastFromZone to WasCast to support copy-correct presuppositions, adding an enters_with_counter field to graveyard and exile cast permissions, and implementing a tracking ledger for counter-placement occurrences to support "first time counters have been put on it this turn" triggers. Additionally, the parser has been updated to handle various complex conditions, such as "During your turn, as long as..." prefixes, gendered/neutral pronouns, and additive count thresholds, while corresponding swallow-check exemptions have been added to prevent false-positive warnings. Since no review comments were provided, I have no feedback to offer on the review itself, but the code changes are highly detailed, rules-compliant, and well-tested.

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.

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 102 card(s), 68 signature(s) (baseline: main 9d7baeecf5b0)

32 card(s) · ability/ChangeZone · field enter_with_counters: [(Generic("finality"), Fixed { value: 1 })][(Finality, Fixed { value: 1 })]

Examples: A-Llanowar Greenwidow, Admiral Brass, Unsinkable, Balustrade Wurm (+29 more)

7 card(s) · ability/PutCounter · field target: parent targetlast created

Examples: Animal Friend, Elemental, My Dear, Furnace-Blessed Conqueror (+4 more)

4 card(s) · ability/PutCounter · field target: selflast created

Examples: Intrude on the Mind, Kianne, Dean of Substance, Lasting Fayth (+1 more)

3 card(s) · static/Continuous · field conditional: another in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (another creature, you) ≥ 1

Examples: Bellowing Elk, Bristlebane Outrider, Saddled Rimestag

2 card(s) · static/CanAttackWithDefender · field conditional: in battlefield you control artifact entered this turn ≥ 1battlefield entries this turn (artifact, you) ≥ 1

Examples: Mechan Shieldmate, Shipwreck Sentry

2 card(s) · static/ExileCastPermission(Cast,unlimited,pool=persistent) · added: ExileCastPermission(Cast,unlimited,pool=persistent) (affects=card)

Examples: Hedonist's Trove, Rona, Disciple of Gix

2 card(s) · replacement/Moved · removed: Moved

Examples: Intrepid Paleontologist, Leonardo, Sewer Samurai

2 card(s) · trigger/Phase · field condition: in battlefield you control artifact entered this turn ≥ 1battlefield entries this turn (artifact, you) ≥ 1

Examples: Akal Pakal, First Among Equals, Robo-

2 card(s) · trigger/Phase · field condition: in battlefield you control planeswalker entered this turn ≥ 1battlefield entries this turn (planeswalker, you) ≥ 1

Examples: Oath of Chandra, Oath of Liliana

2 card(s) · ability/Pump · field conditional: instead if (in battlefield you control land entered this turn ≥ 1)instead if (battlefield entries this turn (land, you) ≥ 1)

Examples: Groundswell, Tomb Hex

1 card(s) · trigger/Attacks · field condition: another in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (another creature, you) ≥ 1

Examples: Waterspout Warden

1 card(s) · trigger/Attacks · field condition: in battlefield you control artifact entered this turn ≥ 1battlefield entries this turn (artifact, you) ≥ 1

Examples: Iron Man, Master of Machines

1 card(s) · trigger/Attacks · field condition: not (source is suspected creature)

Examples: Rubblebelt Braggart

1 card(s) · static/CantAttack · field conditional: not (# of Island ≥ 5)not (# of in battlefield Island ≥ 5)

Examples: Harbor Serpent

1 card(s) · static/CantAttack · field conditional: not (# of Mountain ≥ 1)not (# of in battlefield Mountain ≥ 1)

Examples: Glacial Crasher

1 card(s) · static/CantAttackOrBlock · field conditional: not (unrecognized)not (# of each player whose HandSize { player: Controller } EQ Fixed { value: 0 } ≥ 1)

Examples: Lupine Prototype

1 card(s) · ability/CantBeActivated(all_players) · field duration: until end of turnfor as long as condition

Examples: Braided Net

1 card(s) · static/CantBeBlocked · field conditional: another in battlefield you control Cleric or another in battlefield you control Rogue or another in battlefield you con…battlefield entries this turn (another Cleric or another Rogue or another Warrior or another Wizard, you) ≥ 1

Examples: Nimble Trapfinder

1 card(s) · static/CantBeBlocked · field conditional: in battlefield you control artifact entered this turn ≥ 1battlefield entries this turn (artifact, you) ≥ 1

Examples: Fugitive Droid

1 card(s) · ability/CastFromZone · removed: CastFromZone (target=in exile card + cards exiled by source)

Examples: Rona, Disciple of Gix

1 card(s) · cost/CastingOption:AlternativeCost · added: CastingOption:AlternativeCost

Examples: Blasphemous Edict

1 card(s) · ability/ChangeZone · field conditional: # of another in battlefield creature ≥ 2

Examples: Portcullis

1 card(s) · ability/ChangeZoneAll · field enter_with_counters: [(Generic("finality"), Fixed { value: 1 })][(Finality, Fixed { value: 1 })]

Examples: Shilgengar, Sire of Famine

1 card(s) · trigger/ChangesZone · field condition: object entering battlefield is any of (power >self power toughness >self toughness) creature

Examples: Sharp-Eyed Rookie

1 card(s) · trigger/ChangesZone · field condition: object entering graveyard is toughness <1 creature

Examples: Massacre Girl, Known Killer

… 43 more signature(s) (43 card-changes) — see parse-diff.json
  • 1 card(s) · trigger/ChangesZone · field condition: was cast
  • 1 card(s) · trigger/ChangesZone · field condition: was cast and Dragon/Lesson cards in your graveyard ≥ 3
  • 1 card(s) · static/Continuous · field conditional: another in battlefield you control Knight entered this turn ≥ 1battlefield entries this turn (another Knight, you) ≥ 1
  • 1 card(s) · static/Continuous · field conditional: another in battlefield you control Merfolk entered this turn ≥ 1battlefield entries this turn (another Merfolk, you) ≥ 1
  • 1 card(s) · static/Continuous · field conditional: in battlefield you control artifact entered this turn ≥ 1battlefield entries this turn (artifact, you) ≥ 1
  • 1 card(s) · static/Continuous · field conditional: during your turn and source is equipped
  • 1 card(s) · trigger/CounterAdded · field condition: first time counters put on it this turn
  • 1 card(s) · ability/DealDamage · field conditional: instead if (in battlefield you control land entered this turn ≥ 1)instead if (battlefield entries this turn (land, you) ≥ 1)
  • 1 card(s) · ability/Discard · added: Discard (count=2, target=controller)
  • 1 card(s) · ability/Draw · field conditional: in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (creature, you) ≥ 1
  • 1 card(s) · ability/Draw · field conditional: instead if (in battlefield you control land entered this turn ≥ 1)instead if (battlefield entries this turn (land, you) ≥ 1)
  • 1 card(s) · static/ExileCastPermission(Cast,unlimited,pool=persistent) · added: ExileCastPermission(Cast,unlimited,pool=persistent) (affects=you control creature Dinosaur)
  • 1 card(s) · ability/GainLife · field conditional: instead if (in battlefield you control land entered this turn ≥ 1)instead if (battlefield entries this turn (land, you) ≥ 1)
  • 1 card(s) · static/GraveyardCastPermission(Cast,unlimited) · added: GraveyardCastPermission(Cast,unlimited) (affects=any of (power ≤1 toughness ≤1) creature, conditional=during your turn)
  • 1 card(s) · static/GraveyardCastPermission(Cast,unlimited) · field conditional: creature non-Zombie zone changes this turn (Some(Battlefield)->Some(Graveyard)) ≥ 1
  • 1 card(s) · static/GraveyardCastPermission(Cast,unlimited) · removed: GraveyardCastPermission(Cast,unlimited) (affects=artifact)
  • 1 card(s) · static/GraveyardCastPermission(Cast,unlimited,extra_cost=additional) · added: GraveyardCastPermission(Cast,unlimited,extra_cost=additional) (affects=artifact)
  • 1 card(s) · ability/PayCost · removed: PayCost
  • 1 card(s) · static/PerTurnCastLimit(controller,1) · added: PerTurnCastLimit(controller,1)
  • 1 card(s) · trigger/Phase · field condition: another in battlefield you control Elf entered this turn ≥ 1battlefield entries this turn (another Elf, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: another in battlefield you control Human entered this turn ≥ 1battlefield entries this turn (another Human, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: another in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (another creature, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: face-down in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (face-down creature, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: in battlefield you control creature entered this turn ≥ 1battlefield entries this turn (creature, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: in battlefield you control land entered this turn ≥ 1battlefield entries this turn (land, you) ≥ 1
  • 1 card(s) · trigger/Phase · field condition: # of each player whose HandSize { player: Controller } LE Fixed { value: 1 } ≥ 1
  • 1 card(s) · ability/PumpAll · field conditional: instead if (cast variant was paid)
  • 1 card(s) · ability/PumpAll · field duration: until end of turn
  • 1 card(s) · ability/PutCounter · field conditional: was cast and not (cast from hand)
  • 1 card(s) · ability/PutCounterAll · field conditional: was cast and not (cast from hand)
  • 1 card(s) · static/ReduceCost · field conditional: # of permanent non-land ≥ 10# of in battlefield permanent non-land ≥ 10
  • 1 card(s) · static/ReduceCost · field conditional: you control nontoken zone changes this turn (Some(Graveyard)->None) ≥ 1
  • 1 card(s) · ability/RevealTop · added: RevealTop (count=1, player=controller)
  • 1 card(s) · ability/Sacrifice · field conditional: # of in battlefield creature ≥ 4
  • 1 card(s) · ability/Sacrifice · field conditional: # of in battlefield land ≥ 7
  • 1 card(s) · ability/Tap · field conditional: # of in battlefield Head ≥ 5
  • 1 card(s) · trigger/YouAttack · field condition: attackers declared ≥ 1 and attackers declared ≥ 1
  • 1 card(s) · ability/otherwise · removed: otherwise
  • 1 card(s) · ability/static_structure · added: static_structure
  • 1 card(s) · ability/static_structure · removed: static_structure
  • 1 card(s) · ability/there · added: there (conditional=# of colors among you control permanent = 5)
  • 1 card(s) · ability/there · removed: there
  • 1 card(s) · ability/unknown · removed: unknown

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.

Verdict: changes requested

Substantial, disciplined work — the condition-lane rewrite (in battlefield you control X entered this turn ≥ 1battlefield entries this turn (X, you) ≥ 1) is a clean typed re-representation, all 14 CR citations I spot-checked grep-verify against docs/MagicCompRules.txt, no new enum variants (no sibling-cluster debt), and the allow-noncombinator annotations are in-convention (497 pre-existing uses; the gate honors the documented form). CI is fully green — 12 checks including card-data, the parser gate, and both test shards. Rubblebelt Braggart's not (source is suspected creature) is exactly right.

Three things block it.

1. The population is understated by more than 2x

The body claims "~30 Standard cards" and "+32 supported". The head-current parse-diff sticky (baseline fa199f8021a0) measures 69 cards / 66 signatures.

A wide diff is not automatically scope creep — "build for the class, not the card" produces the same signature, and most of these look like correct collateral repairs. But a reviewer cannot tell the two apart without reading the extras, and the body doesn't list them. Please state the real population card-by-card so the ~39 cards beyond your claim can be checked.

2. Two unexplained capability removals

replacement/Moved removed from Intrepid Paleontologist and Leonardo, Sewer Samurai. Both cards carry finality-counter reminder text — "(If a creature with a finality counter on it would die, exile it instead.)" — and both simultaneously gain a correct cast-permission static, which suggests a deliberate re-seam.

But I can find no generic finality-counter die→exile replacement anywhere in crates/engine/src/game/. If that per-card replacement/Moved was the only thing implementing the exile-instead-of-die behavior, this PR silently deletes finality-counter semantics on both cards, and no test would catch it.

Please either (a) point at where that behavior now lives, or (b) confirm it's a genuine loss and restore it. Same question for the unpaired ability/PayCost removed on one card.

3. A condition is being attached twice

trigger/YouAttack · field condition: ∅ → attackers declared ≥ 1 and attackers declared ≥ 1

The same condition conjoined with itself. It's idempotent, so behavior is likely unaffected — but it means the new lane attaches the condition twice, and the next condition where X ∧ X ≠ X will not be so forgiving. Worth fixing at the attach site rather than the symptom.

Non-blocking

  • ability/there · added: there (conditional=# of colors among you control permanent = 5) — attaching a typed condition to an ability named there (a sentence-fragment misparse the lane didn't create, but is now decorating rather than fixing). Worth a follow-up issue.
  • Size. 8,695 additions / 49 files / 16 commits bundling 13 units (BB1–BB8 + BB-FU1–5) across shared parser infrastructure. Individually the units look sound and the commit boundaries are clean, but this is at the edge of what can be reviewed as one artifact. Future lanes would land faster split at the BB boundary.

Happy to re-review promptly once the population is stated and the removals are explained.

lgray added 17 commits July 12, 2026 10:08
…d cards)

Extend `parse_inner_condition` (the single StaticCondition recognition authority)
so 7 "Activate only if <state>" activated-ability restrictions parse and attach
the rules-correct condition to the `RequiresCondition` gate, clearing the
`SwallowedClause{Condition_If}` warnings (and `DynamicQty` on Cavernous Maw) that
demoted these cards to unsupported. Deliverable is runtime-correct activation
gating, not just the coverage flip.

Cards: Bonecache Overseer, Temple of the Dead, Puca's Eye, Master's Manufactory,
Temple of Cyclical Time, Temple of Power, Cavernous Maw. Also flips Skarrgan
Hellkite (rides the new HasCounters bridge arm — class-generality).

Class-level combinators (no new engine variants; one bridge arm):
- disjunction " or if " connector (CR 602.5 / 603.4)
- N-or-more graveyard-departure threshold (singular "a card" n=1 preserved)
- existential "a player has <hand predicate>" via PlayerAttribute + All (CR 402.1)
- bare "colors among <filter>" quantity ref (CR 105.1/105.2)
- self-inclusive "~ or another <type> entered ... this turn" → the CR 608.2h
  BattlefieldEntriesThisTurn snapshot look-back, Controller-scoped (counts a
  permanent that entered and later left this turn)
- StaticCondition::HasCounters → ParsedCondition bridge arm (source counter gates,
  CR 122.1)
- filtered/plural/[non]combat damage-history threshold (CR 120.2a/b, 120.9, 608.2i)
- additive two-term subtype/zone count threshold (CR 205.3i / 404.1)

Verification: 31 integration tests (per-shape parse-fidelity + discriminating
runtime cond-FALSE/cond-TRUE with hostile discriminators, revert-to-red measured);
coverage +8 GAINED, REGRESSED=0 corpus-wide.

Follow-up (tracked, out of scope): migrate the singular `parse_entered_this_turn_
subject` live-board `EnteredThisTurn` to the same snapshot authority, and add
CR 608.2i to the shared `BattlefieldEntriesThisTurn` annotations for citation
precision.

Assisted-by: ClaudeCode:claude-opus-4.8
…dard cards)

Attach rules-correct intervening-if conditions (CR 603.4 dual-checkpoint: checked
at fire AND at resolution) to five triggered abilities whose condition clause was
recognized by the swallow detector but dropped by the real parser, demoting the
cards to unsupported. Deliverable is runtime-correct trigger gating, not just the
coverage flip.

Cards: Sharp-Eyed Rookie, Massacre Girl (Known Killer), Fearless Swashbuckler,
Rubblebelt Braggart, Stalwart Successor.

Class-level parser combinators (delegate to parse_inner_condition, the single
StaticCondition recognition authority; bridge via
static_condition_to_trigger_condition):
- S1 Sharp-Eyed: possessive entering-object P/T-vs-source comparison; the source
  is read LIVE at resolution (CR 603.4 / 603.6a / 208.1), not latched at fire time
- S2 Massacre: dying-object P/T threshold on the event victim, with a first-refusal
  deferral gate — self-death "if its power was N" yields to the existing
  parse_inner_condition authority (deathknell byte-identical), other-death proceeds
  to the event-object filter (CR 603.6a self vs event subject)
- S3 Fearless: typed-attacker conjunction (And[Pirate>=1, Vehicle>=1] declared this
  combat)
- S4 Rubblebelt: source-suspected state condition
- S5 Stalwart: first-time-this-turn object-counter-added recognizer

New engine variant (add-variant ratified distinct — Nth* are a different enum
TriggerConstraint; CounterAddedThisTurn is per-controller existence):
- TriggerCondition::FirstTimeObjectCountersAddedThisTurn (fieldless; per-object
  first-occurrence over the ObjectCounters ledger, CR 122.1 / 603.4). Walkers
  (ability_rw / ability_scan / coverage) mirror CounterAddedThisTurn's ObjectCounters
  read — NOT Axes::NONE — so CR 603.3b same-event ordering is classified read-not-none
  (FORGE ordering_parity_sweep delta=0).
- swallow_check Duration_ThisTurn allowlist entry "FirstTimeObjectCountersAddedThisTurn"
  (Stalwart's until-end-of-turn duration marker), CR 122.1 / 603.4.

Verification: 22 integration tests (per-shape parse-fidelity + discriminating runtime
cond-FALSE/cond-TRUE, revert-to-red measured). Sharp-Eyed live-source-read non-vacuity:
2/2 fire-time latch grants vs 4/4 live-read declines. Massacre binds dying victim:
tough 0 -> draw, tough 2 -> no-draw. Coverage +5 GAINED, REGRESSED=0 corpus-wide.

Follow-up (tracked, in-branch before PR): BB-FU3 thread trigger dying-subject
structurally (valid_card == SelfRef signal) to replace S2's fork-via-proxy routing
(CR 603.6a) — a signal-cleanliness refinement, not a correctness gap.

Assisted-by: ClaudeCode:claude-opus-4.8
…ers (4 Standard cards)

Extend the parser to recognize+attach the rules-correct cast-origin condition on 4
"was cast [from <zone>]" cards whose condition clause was recognized by the swallow
detector but dropped by the real parser — clearing `SwallowedClause{Condition_If}`
and gating the runtime correctly. ZERO new engine variants: parser-only, reusing the
existing WasCast/CastFromZone primitives across two channels.

Cards: Antiquities on the Loose, Otterball Antics, Anti-Venom (Horrifying Healer),
Ran and Shaw.

Two cast-origin channels (CR 601.2a cast-origin zone, CR 400.7 new-object memory,
CR 603.4 intervening-if):
- Channel A — resolving-spell self-reference (Antiquities, Otterball): passive
  "if this spell was cast from anywhere other than <zone>" -> AbilityCondition::Not(
  CastFromZone{zone}) on the SequentialSibling sub-ability, extending
  strip_cast_from_zone_conditional with the passive-voice arm (positive + negated x
  hand/graveyard/exile). Reads SpellContext.cast_from_zone.
- Channel B — entering-permanent provenance (Anti-Venom, Ran and Shaw):
  * pronoun-subject widening (he/she/they was/were cast) in parse_was_cast_condition
    -> TriggerCondition::WasCast{None,None,None} via the existing bridge.
  * new native And-fold parse_cast_them_and_graveyard_count_intervening_if:
    "if you cast them and there are three or more Dragon and/or Lesson cards in your
    graveyard" -> And[WasCast, QuantityComparison{ZoneCardCount{Graveyard,
    [Dragon,Lesson],Controller}, GE, 3}] (CR 404.1 owner graveyard, CR 205.3
    subtypes), registered before the bare "if you cast it" handler so the conjunction
    is not truncated.

Verification: 16 integration tests (per-shape parse-fidelity + discriminating runtime
cond-FALSE/cond-TRUE with hostile discriminators + measured revert-probes; Ran-Shaw
covers the multi-subtype OR, the Controller scope, and both conjuncts; Anti-Venom
cast-vs-reanimate). Non-regression asserts for the 2 benign collisions (Gideon
reminder-stripped, Spiders-Man web-slinging). Coverage +4 GAINED (13->17),
REGRESSED=0 corpus-wide.

Ceiling (tracked): the negated Channel-A form Not(CastFromZone{Hand}) over-fires on a
spell COPY (cast_from_zone=None, CR 707.10 "a copy isn't cast") — documented in a
ponytail comment + scheduled as BB-FU4 (add AbilityCondition::WasCast, wrap the whole
negated-cast-zone class as And[WasCast, Not(CastFromZone{..})]).

Assisted-by: ClaudeCode:claude-opus-4.8
…rds (Braided Net, Cloud, Tishana's Tidebinder)

Make the parser recognize+attach the rules-correct "as long as" condition on 3
cards whose condition clause was recognized by the swallow detector but dropped —
clearing SwallowedClause{Condition_AsLongAs} and gating the runtime correctly.
ZERO new engine enum variants; ONE field-add (duration) on the existing
CounterSourceRider::LosesAbilities.

Three as-long-as shapes (all reuse pre-existing Duration/StaticCondition types):
- Braided Net (CR 611.2b + CR 110.5 + CR 602.5): "activated abilities can't be
  activated for as long as it remains tapped". The tapped suffix is peeled upstream
  by strip_trailing_duration (-> SourceIsTapped, the wrong scope); a class-scoped
  guard (cant_be_activated_clause_owns_tapped_suffix) keeps the suffix with the
  clause so the CantBeActivated arm emits Duration::ForAsLongAs{IsTapped{scope:Target}}
  bound to the tap target, not the artifact. The guard keys on the "can't be
  activated" + "for as long as <anaphor> remains tapped" pattern (nom combinator, no
  card-name/verbatim match); Braided Net is the sole corpus co-occurrence today, but
  the guard is pattern-scoped for the class.
- Cloud, Planet's Champion (CR 611.3a + CR 109.5 + CR 301.5a): "During your turn, as
  long as ~ is equipped, it has double strike and indestructible". A leading-condition
  peel at the top of parse_subject_continuous_static (both "during your turn, " AND
  "as long as " required — strictly narrower than a during-optional gate, so it never
  intercepts bare as-long-as statics owned by parse_conditional_static) attaches
  condition: And{[DuringYourTurn, SourceIsEquipped]} to the granted static.
- Tishana's Tidebinder (CR 611.2a): "loses all abilities for as long as this creature
  remains on the battlefield". Surface the already-enforced UntilHostLeavesPlay
  duration into the AST via a new duration field on CounterSourceRider::LosesAbilities
  (Box<Duration>, serde-default back-compat); apply_source_static now reads the field
  instead of a hard-coded literal (behavior identical).

Runtime enforcement for all three already existed — these are parser-attach plus one
field-surfacing change.

Verification: 5 integration + 5 unit tests (parse-fidelity + discriminating runtime
ON->OFF with named revert-probes; Tishana field-threading proven by a non-default
duration sentinel driven through the real resolve()). F1 non-vacuity: the
both-prefixes peel does not shadow plain during-your-turn statics (39-card is_none()
guard) and counter-animation cards (Kaito) fall through unchanged. Corpus coverage:
+3 GAINED (braided net, cloud, tishana), REGRESSED=0 corpus-wide; the shared
strip_trailing_duration guard touches only Braided Net (jq: sole "remains tapped" +
"can't be activated" co-occurrence of 42 remains-tapped cards), and the other two
"during your turn, as long as" cards (elvish refueler, evendo brushrazer) are
byte-identical before/after.

Assisted-by: ClaudeCode:claude-opus-4.8
…falling back to Normal (CR 601.2a)

When a static ExileCastPermission grants a cast and the exiled card is the ONLY
cast candidate, handle_cast_spell_with_payment_mode skipped variant election (the
had_multiple_candidates guard) and continued as a Normal cast — dropping the
ExilePermission context: WithoutPayingManaCost mana-zeroing, the OncePerTurn slot
stamp, and (once wired) the finality enters-with rider. The sole exiled card then
failed prepare_spell_cast's castable-zone check ("Card is not in a castable zone"),
since owned-exile-under-a-permission is not a castable_zone branch.

Elect the single option when it IS an ExilePermission, so the permission's casting
variant (mana permission, slot, source) is honored. Structurally scoped: an
ExilePermission candidate is only ever pushed for a card in Zone::Exile, so a
Normal/GraveyardPermission single candidate — every hand cast (Warp/Evoke/Dash) and
graveyard cast (which already has a castable_zone branch) — is untouched and falls
through byte-identically. Scoped to ExilePermission with a ponytail comment naming
the general predicate + upgrade path (the general form would preempt single-candidate
hand casts that route through their own cost-choice handlers).

Latent-bug fix for Maralen, Fae Ascendant ("Once each turn, you may cast a spell …
from among cards exiled with Maralen this turn without paying its mana cost",
CR 118.9a): pre-fix its single-candidate cast charged full mana and skipped the
once-per-turn slot. Also the runtime prerequisite for the L02 BB3 finality-on-
exile-cast placement (Intrepid Paleontologist).

Tests: single_candidate_exile_permission_elects_permission_and_zeros_mana (mana
zeroed + slot stamped; reverting the election -> castable-zone error, both asserts
flip) and single_candidate_normal_hand_cast_unchanged_by_exile_election (a Normal
single-candidate hand cast still elects Normal, no slot leak — the gated-scope guard).

Assisted-by: ClaudeCode:claude-opus-4.8
…(Noctis, Intrepid, Leonardo)

Make the parser recognize+attach the rules-correct cast-permission and its linked
"if you cast a spell this way, that permanent enters with a finality counter"
clause on 3 Standard cards whose clauses were recognized by the swallow detector
but dropped or misparsed — clearing SwallowedClause{Condition_If} (all three) and
SwallowedClause{Optional_YouMay} (Intrepid, Leonardo). ZERO new engine enum
variants; ONE new field enters_with_counter: Option<CounterType> on the two sibling
static modes GraveyardCastPermission + ExileCastPermission, mirroring the existing
Effect::CastFromZone.enters_with_counter.

Root cause was the CLASSIFIER, not the leaf combinators: Leonardo's leading "During
your turn," qualifier and Intrepid's "cards you own exiled with" infix were not
routed to the Priority-7 static dispatch, so they fell to the Priority-8 replacement
gate and misparsed into a garbage PutCounter replacement. The classifier gains a
"during your turn, " leading-qualifier arm + a "from among cards you own exiled with"
zone-anchor tolerance (both narrow widenings of existing anchors).

- Noctis, Prince of Lucis (FIN 235, CR 601.2f + CR 122.1 + CR 614.1c): captures the
  "by paying 3 life in addition to paying their other costs" extra_cost (an
  opt("paying ") fix on the additional-cost rider) and the finality rider.
- Intrepid Paleontologist (LCI 193, CR 108.3 + CR 109.5 + CR 113.6b): ExileCastPermission
  (Persistent pool, owner=You, Dinosaur creature) + finality. Its exile pool
  auto-populates: the {2} exile ability is source-scanned via LINKED_EXILE_CONSUMER_TAGS
  (which lists "ExileCastPermission") -> TrackedBySource -> exile_links, zero extra code.
- Leonardo, Sewer Samurai (TMT 17, CR 109.5): GraveyardCastPermission gated to the
  controller's turn (StaticCondition::DuringYourTurn, power-or-toughness <=1 creatures)
  + finality.

A shared pub(crate) parse_cast_this_way_enters_with_counter recognizer (extracted from
the existing effect-path try_parse_cast_this_way_enters_rider + a new "that artifact"
subject arm; effect path byte-identical) sets the field on the static constructors;
a new finalize_cast reader (selected_static_permission_enters_with_counter, keyed on
the zone-stable casting_variant.source) places the counter via pending_etb_counters
at the seam. CR 601.1/607.1 (linked "this way"). Requires the election-fix engine
commit that precedes this so a single-candidate exile cast is elected as
ExilePermission rather than Normal.

Scope: finality counter PLACEMENT only. CR 122.1h death->exile enforcement does not
exist engine-wide and is a known orthogonal follow-up (mirror shield counters CR
122.1c) — this change does not claim it.

The class-level building blocks correctly generalize beyond the 3 targets (verified
by corpus warndiff): Hedonist's Trove and Rona, Disciple of Gix now parse their
ExileCastPermission (were silently dropping the clause), and Advanced Floral
Invocations now captures its finality rider. Dawnhand Dissident (whose "by removing
three counters" cost is unmodeled) is correctly DECLINED by the builder and stays a
clean Unimplemented gap — no misparse.

Verification: 13 integration tests (per-card parse-fidelity + discriminating runtime
ON/OFF with named revert-probes; Intrepid drives the real {2}-exile -> pool -> cast ->
finality path; owner=You + P/T + timing gates each discriminated). Corpus coverage:
+4 GAINED (Noctis, Intrepid, Leonardo, Hedonist's Trove), REGRESSED=0 corpus-wide;
per-card diff of every status-changed card confirms all changes are correct gains or
a clean decline (Dawnhand).

Assisted-by: ClaudeCode:claude-opus-4.8
…ter, Freestrider Commando)

Make the parser recognize+attach the rules-correct condition on 2 Standard cards
whose "enters-with-counter gated by a condition" clause was recognized by the swallow
detector but dropped — clearing SwallowedClause{Condition_If} and gating the runtime
correctly. ZERO new engine enum variants, fields, or types: both fixes attach
pre-existing typed conditions onto structures the parser already builds, plus one
additive engine reader change.

- Freestrider Commando (OTJ, CR 601.2h + CR 608.2c): "if it wasn't cast or no mana was
  spent to cast it" -> a single rules-correct leaf StaticCondition::QuantityComparison{
  Ref(ManaSpentToCast{SelfObject, Total}), EQ, Fixed{0} } (both disjuncts <=> 0 mana
  spent; Plot is a cast per CR 702.170d, so the mana disjunct is load-bearing). The
  whole-phrase recognizer is hoisted to ARM 1 of parse_single_inner_condition so the
  phrase collapses to one leaf (not Or[Not(WasCast), ...], which cannot map to a
  ReplacementCondition); it flows through the existing extract_enters_with_only_if_suffix
  -> replacement_condition_from_static (QuantityComparison -> OnlyIfQuantity) pipeline.
  Blacksnag Buzzard is the structural precedent.
- Undead Sprinter (DSK): (a) the game-state gate "if a non-Zombie creature died this
  turn" -> StaticDefinition.condition = QuantityComparison{ Ref(ZoneChangeCountThisTurn{
  Battlefield, Graveyard, Typed(Creature + Non(Subtype("Zombie"))) }), GE, Fixed{1} }
  (a filtered arm of parse_creature_died_this_turn_conditions, placed after the bare
  literal so Morbid keeps the unfiltered ref); (b) the linked "If you do, this creature
  enters with a +1/+1 counter" rider -> the BB3 enters_with_counter field =
  Some(CounterType::Plus1Plus1), via a new "if you do" split marker + a "~ enters with "
  subject arm ("this creature" normalizes to ~ before the graveyard-permission parser)
  + a rest.is_empty() -> rest.trim().is_empty() fix for the two-sentence residual.

The one engine change (CR 607.1 + CR 601.3 + CR 113.6b): a self-granting fallback in the
BB3 finalize_cast reader selected_static_permission_enters_with_counter. Undead Sprinter
grants ITSELF the graveyard-cast permission, so at the finalize seam the source has moved
graveyard->Stack and its Graveyard-scoped permission no longer "functions in zone"
(CR 113.6b) -> the primary active_static_definitions path yields None -> the +1/+1 would
never be placed. The fallback reads the enters-with rider directly from the printed def
(the permission that authorized this cast is committed in casting_variant; the rider is a
linked ability to the directly-printed permission per CR 607.1). It fires ONLY when the
primary path is None via .or_else(), so BB3's separate-battlefield-source cards
(Noctis/Leonardo/Intrepid, whose permission still functions in zone) are byte-identical.
It is a class fix for any self-granting-permission-with-rider card.

The class-level building blocks correctly generalize beyond the 2 targets (verified by
corpus warndiff): Hundred-Battle Veteran (a self-granting graveyard-cast card with an
"if you do -> finality counter" rider) now captures + places its finality rider (was
silently dropped). Ebondeath, Dracolich's name-negation "a creature not named Ebondeath
died this turn" is correctly NOT claimed by the filtered died arm (name-negation leftover
-> arm rejects) -> it stays a clean gap (a separate follow-up). Primeval Spawn is
byte-identical.

Scope: the finality/+1/+1 counter is PLACED. The Freestrider stale-field edge
(mana_spent_to_cast_amount is not scrubbed in reset_for_battlefield_entry, so a
countered-then-reanimated-same-object Freestrider mis-reads a stale value) is a
pre-existing codebase-wide field-hygiene bug (shared by Lavinia/Satoru mana-spent
readers) that the single-leaf inherits; it is correct on every realistic cast path
(hard-cast 0 / Plot 2 / reanimate-fresh 2). The rules-correct fix needs a per-incarnation
was-cast disjunction (ReplacementCondition::Or, absent) and is filed as a follow-up with
the reset-hygiene fix as one coherent cluster.

Verification: 11 integration tests (per-card parse-fidelity + discriminating runtime
ON/OFF with named revert-probes; the reader-fallback discriminator flips counter 1->0
when disabled; the Freestrider gate flips 0->2 counters on a hard-cast when the condition
is dropped). Corpus coverage: +2 GAINED (Undead Sprinter, Freestrider Commando),
REGRESSED=0 corpus-wide; the whole-corpus per-card diff confirms the only other change is
the correct Hundred-Battle Veteran generalization.

Assisted-by: ClaudeCode:claude-opus-4.8
…es (Molten Exhale, Blasphemous Edict, Sandman's Quicksand)

Make the parser recognize+attach the rules-correct condition on 3 Standard cards
whose cast-context / alternative-cost clause was recognized by the swallow detector
but dropped — clearing SwallowedClause{Condition_If} and gating the runtime
correctly. ONE new engine leaf (CastVariantPaid::Mayhem); no new fields or types.

- Molten Exhale (TDM, CR 601.2f + CR 702.8): swallow-only false-positive. The
  "you may cast this as though it had flash if you behold a Dragon as an additional
  cost" clause is ALREADY represented + runtime-enforced (casting_options[0] =
  AsThoughHadFlash + Behold(Dragon); flash_timing_cost grants the flash only if the
  behold is payable, and the behold is collected as AdditionalCost::Required). The
  swallow fired only because has_pay_phrase (swallow_check.rs) recognized "if you
  pay" but not "as an additional cost". Broaden it; the structural AND-half
  (casting_options[].cost.is_some()) keeps genuinely-unrepresented "if" clauses
  unexempt. Zero parser/engine change.
- Blasphemous Edict (DFT, CR 118.9): the alternative cost "you may pay {B} rather
  than pay this spell's mana cost if there are thirteen or more creatures on the
  battlefield" is gated by a game-state count. New parse_type_count_on_battlefield
  arm in parse_quantity_ref -> QuantityRef::ObjectCount { filter }. Routed through
  ObjectCount (which counts battlefield objects for both controllers), NOT the
  battlefield-blind ZoneCoreTypeCardCountAtLeast (whose player_zone_ids returns
  empty for Battlefield -> would brick the alt-cost at 0 >= 13 forever). The
  casting-option gate (can_cast_object_now -> payable_spell_alternative_cost) was
  already wired.
- Sandman's Quicksand (EOE, CR 702.187b + CR 608.2c): "all creatures get -2/-2;
  if this spell's mayhem cost was paid, creatures your opponents control get -2/-2
  instead" -> a modal effect gated on the alt-cost-paid cast context. New
  CastVariantPaid::Mayhem (a unit leaf, sibling of Warp in the established
  per-keyword family) + a ("mayhem cost was paid", Mayhem) row in
  CAST_VARIANT_COST_PAID_PHRASES + widening the Emerge-only membership filter to
  matches!(v, Emerge | Mayhem) + a cast-time stamp in
  finalize_cast_with_phyrexian_choices (mirrors the adjacent Foretell/Impending
  arms; fires only for CastingVariant::Mayhem, so existing Mayhem cards gain an
  inert marker). The resulting ConditionInstead{CastVariantPaid{Mayhem, Source}}
  AST is byte-identical to shipping Adipose Offspring modulo the variant string.
  cast_variant_paid is reset in reset_for_battlefield_entry (CR 400.7) and read the
  same resolution -> no staleness window.

parse_quantity_ref arm placement: the arm is last-priority within the combinator
with an end-anchor guard, so it cannot shadow parse_greatest_commander_mana_value_ref
("...commander you own on the battlefield or in the command zone") and only claims
previously-failing bare "<type> on the battlefield" forms. It feeds the full phrase
to parse_type_phrase, preserving the InZone(Battlefield) filter property.

Class-generalization (build-for-the-class, verified by whole-corpus warndiff):
the parse_quantity_ref arm correctly generalizes to 7 previously-mishandled cards,
all gains, REGRESSED=0:
- portcullis: SwallowedClause cleared; condition attached as
  ObjectCount{Creature + Another + InZone(Battlefield)} GE 2 ("two or more OTHER
  creatures on the battlefield" — the Another prop is captured, no over-count).
- planar collapse / impending disaster: fix a latent CR 603.4 rules bug — the
  intervening-if trigger condition was None, so they fired UNCONDITIONALLY every
  upkeep (destroy all creatures/lands regardless of the 4+/7+ gate). Now correctly
  typed QuantityCheck GE 4/7 and enforced at resolution.
- neck tangle: condition attached (Heads GE 5); the "Otherwise" branch is promoted
  from a buried Unimplemented to a structured else_ability (Unimplemented 2 -> 1);
  the main tap-Heads effect is intact.
- glacial crasher / harbor serpent / hour of revelation: gain an explicit
  InZone(Battlefield) on a filter that already defaulted to Battlefield at runtime
  (runtime-equivalent, more Oracle-faithful).

Verification: 9 integration tests (per-card parse-fidelity + discriminating runtime).
Blasphemous: alt-cost AVAILABLE at 13 creatures / UNAVAILABLE at 12 on identical
boards via can_cast_object_now. Sandman: mayhem cast shrinks opponents only; the
cast-time stamp is load-bearing (a stamp-revert-probe flips the controller's own
creature from unaffected to -2/-2). Planar Collapse: fires at 4 creatures, fizzles
at 3 (the runtime lock on the latent-bug fix, via the production upkeep -> resolve
path). issue_2354 (Norman Osborn Mayhem) stays green. Corpus: GAINED = 4 (3 targets
+ portcullis), REGRESSED = 0 corpus-wide; the whole-corpus per-card diff confirms
the only other changes are the 6 correct class-generalizations above.

Assisted-by: ClaudeCode:claude-opus-4.8
…y alt-cost & goad "if able" (Valgavoth, Maximum Carnage)

Two Standard cards fired SwallowedClause{Condition_If} as swallow-detector
FALSE-POSITIVES: their representations already exist AND are runtime-enforced, so
the detector was spuriously demoting supported cards. Clear the false-positives —
swallow_check.rs only, zero parser/engine/variant change.

- Valgavoth, Terror Eater (DSK, CR 118.9 + CR 607.1 + CR 608.2c): "if you cast a
  spell this way, pay life equal to its mana value rather than pay its mana cost"
  is ALREADY represented as `ExileCastPermission.extra_cost = {PayLife(SelfManaValue),
  mode: Alternative}` (built by the shipped `try_parse_alt_cost_rider`), and the
  "would be put into an opponent's graveyard … exile it instead" replacement is
  already represented + stripped. The alt-cost rider is the only unmatched "if". New
  suppression helper `cast_this_way_alt_cost_is_only_if_marker` (sibling of
  `enters_with_finality_this_way_is_only_if_marker`): fires when an Alternative-mode
  exile/graveyard cast-permission rider is present and, after stripping that rider
  via `try_parse_alt_cost_rider`, no OTHER bare " if " survives. Keys on
  `CastCostMode::Alternative` ONLY, so `Additional`-mode riders (Festival of Embers)
  are untouched. Class: every exile/graveyard cast-this-way permission with a
  CR 118.9 alternative-cost rider.
- Maximum Carnage (BLB, CR 508.1d + CR 701.15b): chapter I "each creature attacks
  each combat if able and attacks a player other than you if able" already lowers to
  `Effect::GoadAll`, which runtime-enforces the must-attack-if-able requirement
  (shipped `goad.rs` resolver test). The `cond_markers` "if able" cluster omitted
  `GoadAll`; add the `"type":"GoadAll"` marker.

Class-generalization (verified by whole-corpus warndiff): the GoadAll marker also
legitimately clears Kardur, Doomscourge ("creatures your opponents control attack
each combat if able and attack a player other than you if able" → `GoadAll{Opponent}`)
— a 2-card class proof, not a one-off.

Follow-up (out of scope): `GoadAll` also marks `goaded_by`, but these cards do not
goad (Maximum Carnage's ruling: the ability "doesn't cause any creatures to become
goaded"). That is a pre-existing over-lowering of non-goad "attacks if able" to
GoadAll, tracked separately; the swallow fix here is honest about the "if able"
clause specifically (GoadAll's must-attack DOES enforce it), independent of the
goaded-status over-representation.

The third BB8 card, The Tale of Tamiyo, is a genuine gap ("if two cards that share a
card type were milled this way, draw a card and repeat this process" — a shared
class with Grindstone). Its fix requires a new AbilityCondition leaf plus a shared
chain-body change so the milled set survives the intervening draw; that change has a
codebase-wide verification surface, so it is deferred to a focused standalone engine
PR and left as honest `Unimplemented` here — not silently half-fixed.

Verification: 7 swallow-detector unit tests (revert-probes discriminating and
non-vacuous — parsed with real card context so no Unimplemented early-return
short-circuits the audit) + 1 runtime test pinning the Alternative-vs-Additional
axis (Alternative zeroes the mana cost, Additional keeps it due). Corpus: exactly 3
cards clear {Valgavoth, Maximum Carnage, Kardur}, each with real backing
(`extra_cost:Alternative` / `GoadAll`), REGRESSED=0 corpus-wide, no masked drops;
The Tale of Tamiyo still carries its Condition_If (honest deferral).

Assisted-by: ClaudeCode:claude-opus-4.8
…terball / Match-the-Odds class)

A counter anaphor ("it" / "that creature" / "that token" / "the token") placed
after a token-creating clause in the same resolution chain was misbinding to the
source (`SelfRef`) or an unbound `ParentTarget` instead of the created token
(`TargetFilter::LastCreated`) — so the counter was a no-op or landed on the wrong
object. This is a LIVE rules bug: Otterball Antics, flipped to "supported" by the
L02 cast-origin condition (BB2, ce4b00ddc), silently placed 0 counters on its Otter
token on every graveyard/flashback cast. The same root affects a pre-existing
for-each cluster (Match the Odds, Lasting Fayth, …).

Two parser dispatch paths produced the misbind; both now converge on one shared
building block — no new enum variant or field, parse-time AST only (`LastCreated`
already resolves at runtime):

- New helper `counter_anaphor_created_token_binding(anaphor, ctx) -> Option<TargetFilter>`
  (oracle_effect/counter.rs), keyed on the existing `ctx.token_created_in_chain`.
  Recognizes the anaphor CLASS via a nom `alt` — `is_it_pronoun` ∪ `{that creature,
  that token, that permanent, the token, the permanent}` — returning
  `Some(LastCreated)` iff `token_created_in_chain && (is_it_pronoun(a) || ctx.subject
  ∈ {None, SelfRef, Any})`. The `is_it_pronoun` path is ungated (identical to the
  prior inline it-branch — behavior-preserving); the demonstratives are subject-gated
  so Pip-Boy's non-self "that creature → TriggeringSource" (resolved earlier by
  `resolve_that_creature_in_trigger`) is preserved. "the creature" is excluded (it is
  ambiguous — Longstalk Brawl legitimately binds it to a chosen target slot).
- Call site A — `resolve_counter_placement_target` (counter.rs): the it-branch is
  refactored to `helper(...).unwrap_or_else(resolve_it_pronoun)` (byte-identical for
  all it inputs), and a new demonstrative arm is added AFTER the
  `resolve_that_creature_in_trigger` check.
- Call site B — `try_parse_for_each_effect` counter arm (mod.rs): now consults the
  same helper with the REAL seeded `ctx` before the
  `parse_subject_application(ParseContext::default())` fallback (which had discarded
  the seeded ctx → `token_created_in_chain=false`). The for-each dispatch intercepts
  before the imperative counter path, so the two call sites are disjoint per clause.

Class fix, verified by whole-corpus per-card diff: exactly 11 cards flip a single
PutCounter target `SelfRef`/`ParentTarget → LastCreated` — animal friend, elemental
my dear, furnace-blessed conqueror, grist the plague swarm, intrude on the mind,
kianne, lasting fayth, match the odds, otterball antics, outlaw stitcher, retrieve
the esper — REGRESSED=0, each rules-correct against Oracle text. Guards unchanged:
the 5 existing `LastCreated` "it" cards, North Pole Research Base ("it" = a tapped
real target → `ParentTarget`), Immaculate Magistrate (for-each "target creature" →
`Typed`), the 9 legitimate self-ref counters, and the out-of-scope cards.

Scope: the reflexive "When you do" trigger-body case (Iroh, Tea Master) is NOT fixed
here — its blocker is a distinct `token_created_in_chain`-seeding gap in the
reflexive body parse, not this parse-time anaphor helper; it stays `ParentTarget`
before and after (no regression) and is a standalone follow-up. The
"the token" ≠ always-the-created-token counterexamples (Elder Arthur Maxson /
Phyrexian Cytoshaper, where "that token" is a triggering/granted token) confirm the
`token_created_in_chain` guard is load-bearing — a flag bypass would be unsound.

Verification: 4 discriminating runtime tests measuring RESOLVED counter deltas via
the production parse+resolve path, with revert-probes: Match the Odds (call site B,
3 counters on the token → 0 on revert), grist the plague swarm (call site A
demonstrative + "the token", 1 → 0 on revert, reach-guarded so a condition-fizzle
can't pass vacuously), Applied Geometry (it-branch, exactly 6 counters — a
double-seed would be 12), and Longstalk Brawl ("the creature" stays a chosen-target
binding, not `LastCreated`).

CR 608.2c (read the whole text; a later same-effect anaphor binds the just-created
object) + CR 111.10 (predefined token creation) + CR 122.1 (a counter is a marker on
an object).

Assisted-by: ClaudeCode:claude-opus-4.8
…Permission land-play construction (BB3 gap)

BB3 (d09e2bb7a) added the required field `enters_with_counter: Option<CounterType>`
to `StaticMode::GraveyardCastPermission` but never threaded it into the sole
cross-crate construction site in mtgish-import (`MayPlayLandsFromGraveyard` →
`GraveyardCastPermission`). BB3 was verified only with `cargo check -p engine`,
which never compiles the sibling mtgish-import crate, so the workspace build
shipped red. `None` is rules-correct: playing a land from the graveyard is a
special action (CR 116.2a), never a spell (CR 305.1), so it is never cast and
never enters with a finality counter (CR 122).

Assisted-by: ClaudeCode:claude-opus-4.8
… cast-origin (CR 707.10)

A resolving-spell cast-origin condition ("if this spell was cast from anywhere
other than your hand"), lowered by BB2 to `AbilityCondition::Not(CastFromZone{Hand})`,
over-fired on a spell COPY: a copy has `cast_from_zone = None` (CR 707.10 — a copy
of a spell isn't cast; CR 400.7 — a new object with no memory of prior existence),
so `Not(None == Some(Hand))` = `Not(false)` = TRUE, wrongly granting the copy's
effect (Antiquities on the Loose's `PutCounterAll`) even though a copy was never
cast. Correct evaluation is FALSE.

Two-part fix, both semantics-preserving for existing producers:

- Option-B rename `AbilityCondition::CastFromZone{zone:Zone}` →
  `WasCast{zone:Option<Zone>}`, mirroring the pre-existing `StaticCondition::WasCast`
  and `TriggerCondition::WasCast` (parameterize an existing axis rather than
  proliferate a sibling variant). Eval:
  `cast_from_zone.is_some_and(|cz| zone.is_none_or(|z| cz == z))` — a copy's `None`
  yields false; `zone: None` means "cast from any origin". Serde
  `#[serde(alias = "CastFromZone")]` + `skip_serializing_if` keep old payloads
  deserializable and `Some(X)` bodies byte-stable.
- Restore the dropped ∃cast conjunct ONLY on the "anywhere other than X" producer:
  `And[WasCast{None}, Not(WasCast{Some(Hand)})]`. The sibling "you didn't cast it
  from X" producer is DELIBERATELY left bare `Not(WasCast{Some(X)})` — its opposite
  semantics (¬(cast ∧ origin=X) → a reanimated/copied Phage-style card still
  triggers "you lose") is canonical and must not gain the ∃cast guard.

Whole-corpus coverage: REGRESSED=0; the ~25 cards that change serialization shape
all do so by exactly the rename or the And-wrap (structural per-node diff, zero
unexpected residual). The rename is coverage-neutral (the coverage report key
stays "CastFromZone"; And / WasCast are all Handled). `cargo check --workspace`
is green (the rename also lands in mtgish-import/src/convert/condition.rs).

Verification: `l02_bbfu4_copy_cast_origin.rs` casts Antiquities from hand, resolves
a COPY off the stack via the production pipeline, and asserts the copy grants 0
counters — MEASURED discriminating: reverting the And-wrap flips it to counters
[0,1,0,1] (sum 2). A parse-level fence
(`bbfu4_only_anywhere_other_than_gains_existential_cast_conjunct`) proves the ∃cast
wrap lands on the "anywhere other than" arm only, leaving "didn't cast it" bare.
Phage's "didn't cast it ⇒ lose" reaches the engine via `TriggerCondition::WasCast`
(untouched), asserted by a parse-attribution fence.

CR 707.10 / CR 601.2a / CR 400.7 / CR 603.4.

Assisted-by: ClaudeCode:claude-opus-4.8
… loss (untouched TriggerCondition::WasCast path)

Adds a runtime regression fence for the "if you didn't cast it from your hand,
you lose the game" trigger family (Phage the Untouchable), which had zero
runtime loss-outcome coverage. This path is UNTOUCHED by the WasCast copy-
correctness change — Phage lowers to `TriggerCondition::Not(WasCast{Hand})`,
whereas that change renamed only `AbilityCondition::CastFromZone`. The fence
therefore cannot flip on reverting the rename; it is a standing regression
fence, not a delta discriminator (labeled as such in-code).

Non-vacuity is the positive+negative pair on the same card with opposite cast
origin: a reanimated Phage (put onto the battlefield, cast_from_zone=None per
CR 400.7 → "didn't cast it from your hand" TRUE) eliminates its controller
(CR 104.3e), while a hand-cast Phage (WasCast{Hand} TRUE → Not(..) FALSE) does
not. Reuses this file's existing {0}-Reanimate pattern — no new scaffolding.

CR 104.3e / CR 400.7 / CR 800.4a.

Assisted-by: ClaudeCode:claude-opus-4.8
…Turn ledger, not the live board (CR 608.2i)

The bare-subject "entered this turn" count/existence gate (Gargoyle Flock's
"if a creature entered the battlefield under your control this turn…", Oath of
Chandra/Liliana, Akal Pakal, …) parsed to `QuantityRef::EnteredThisTurn`, whose
runtime read counts only objects still on the battlefield (`zone == Battlefield`).
A creature that entered under your control this turn and then died, was bounced,
or was sacrificed before the gate evaluates was excluded → the look-back
under-counted. CR 608.2i: an effect that looks back in time for objects it takes
no action on does not require them to still be in the zone they were in — an
explicit exception to CR 608.2h. This is a pre-existing (inherited) upstream
inconsistency: the sibling forms ("N or more … entered", the "~ or another"
disjunct) already read the departure-surviving `BattlefieldEntriesThisTurn`
ledger; only this bare-subject branch was left on the live board.

Fix (surgical mirror of the migrated siblings): drop `inject_controller_you` and
emit `QuantityRef::BattlefieldEntriesThisTurn { player: PlayerScope::Controller,
filter }` — byte-identical in shape to `parse_or_more_entered_count`. No new
variant; the ledger read and its matcher (controller / type / `FilterProp::Another`)
already exist. "under your control" moves from a filter-baked `ControllerRef::You`
to `PlayerScope::Controller`.

Sub-form boundary held: the same-named TargetFilter form ("if the creature you
control entered this turn, put a +1/+1 counter on it", Malamet Battle Glyph) is a
legal-target form and stays on the live board — a departed object is not a legal
target (CR 608.2h). It is deliberately untouched.

This is a parser change: the `parse_details` serialization of the ~32 bare-subject
"entered this turn" count cards flips `EnteredThisTurn → BattlefieldEntriesThisTurn`
(controller off the filter, `PlayerScope::Controller` on the variant). Both variants
are `Handled` in coverage → whole-corpus REGRESSED=0 (a representation-only change,
the intended ledger-consistency migration). The coverage-parse-diff bot will flag
these cards; that flag is expected here, not a regression.

Verification: a runtime look-back test on Gargoyle Flock resolving through the
production `BattlefieldEntriesThisTurn` arm — a creature that entered this turn then
departed still satisfies the gate; reverting to the old `EnteredThisTurn` emit makes
the departed creature fail the `zone == Battlefield` check → count 0 → gate FALSE →
the assertion flips red — paired with an entered-and-stayed no-regression control, an
opponent-entrant control proving `PlayerScope::Controller`, and a parse-boundary lock
on Malamet proving the target form stays `TargetMatchesFilter { use_lki: false }`
(flips if the act-on form is ever over-migrated). The 3 existing parse tests are
migrated to the new authority.

A sibling producer (`parse_entered_this_turn_ref`, the "[type] that entered this
turn" surface) has the same live-board bug but an optional any-player controller that
`BattlefieldEntriesThisTurn`'s `PlayerScope` cannot yet express; tracked as a separate
follow-up.

CR 608.2i / CR 608.2h.

Assisted-by: ClaudeCode:claude-opus-4.8
… Stalwart's first-time gate (CR 603.2c)

Stalwart Successor ("Whenever one or more counters are put on a creature you control, if
it's the first time counters have been put on that creature this turn, put a +1/+1 counter
on that creature") missed its trigger whenever the turn's first counter placement on a
creature added two or more DIFFERENT counter types at once. BB4 (this branch) introduced the
`FirstTimeObjectCountersAddedThisTurn` condition with a per-record `count(object_id) == 1`
model and documented this as the R7 fail-safe ceiling; this is that fix.

Tracing showed the ceiling masked a second bug: the "one or more counters" CounterAdded
trigger was `batched:false`, so a multi-type placement (two `CounterAdded` events) was
checked per-event — `count() == 1` was doing double duty as both the first-time gate AND the
only guard against a double-fire. A condition-only fix would over-fire. The correct fix is
two-layered:

- Batch the class (R1, CR 603.2c). Set `batched=true` for the "one or more counters"
  CounterAdded form, gated at the effect-known lowering site behind
  `!matches!(effect, Effect::Unimplemented)` so the three "same number and kind" reproduction
  cards (Bold Plagiarist, Aragorn Company Leader, Captain Marvel Apex Avenger) — whose
  per-kind reproduction isn't a typed effect yet — stay `batched:false`. This batches 7
  CounterAdded triggers (Stalwart, All Will Be One, Generous Patron, Kros Defense Contractor,
  Putrid Hexhag, Rikku Resourceful Guardian, and Danny Pink's granted trigger), collapsing a
  multi-type placement to one firing per placement occurrence.
- Aggregate the batched magnitude (CR 608.2). Move `CounterAdded` out of the zero-arm of
  `count_matching_trigger_event_subjects` into a dedicated arm returning the counter magnitude,
  so a batched "deals that much damage" trigger (All Will Be One) reads the total counters
  across the batch rather than `Some(0)`. Reachable only via the batched path; non-batched
  triggers are unaffected (they read `extract_amount_from_event`).
- Occurrence-count the first-time gate (R2, CR 122.1 + CR 603.4). Add
  `object_counter_placement_count_this_turn`, an occurrence ledger bumped once per object per
  batch (mirroring the already-correct `object_tap_count_this_turn` /
  `FirstTimeObjectTappedThisTurn` sibling), and read `FirstTimeObjectCountersAddedThisTurn`
  from it (`== Some(1)`) instead of scanning per-type records. The new map is EXCLUDED from
  `GameState`'s `PartialEq` (mirroring the tap map) so this per-turn observational counter does
  not perturb loop-state board-equality.

The three excluded per-kind cards are deferred to a standalone follow-up (batched
counter-trigger per-kind reproduction aggregation); the scope predicate keys on the
`Effect::Unimplemented` variant and must be re-gated when that reproduction becomes a typed
effect.

Verification: three revert-probes, each measured to flip — reverting R1 makes All Will Be One
deal `[1,1]` (two per-event fires) not `[2]`; reverting the magnitude arm makes it deal `[]`
(0); reverting R2 makes Stalwart's multi-type-first fire zero times. Whole-corpus coverage
REGRESSED=0, FORGE `ordering_parity_sweep` delta=0, and the card-data snapshot delta is the
`batched` flag on exactly those 7 cards (the three per-kind cards byte-identical).

CR 603.2c / CR 608.2 / CR 122.1 / CR 603.4.

Assisted-by: ClaudeCode:claude-opus-4.8
…on dispatch — structural self-vs-other "its" binding (CR 603.6c)

The dying-object P/T intervening-if gate ("When ... dies, if its <stat> was N",
`parse_dying_object_pt_comparison_condition`) disambiguated self-death from
other-death by a PROXY: "did `parse_inner_condition` already hoist the clause?"
That proxy is correct today only by a coverage asymmetry
(`parse_source_power_toughness_condition` is postfix-only), and would mis-bind an
other-death POSTFIX card ("a creature an opponent controls dies, if its power was
3 or greater") to the SOURCE instead of the dying event object. No such card
exists in the DB yet — this is a latent hardening of BB4's own gate, retiring a
fragile proxy and its `ponytail:` IOU.

Replace the proxy with a structural discriminator: thread the trigger's dying
subject (`Option<&TargetFilter>`) down the single-call-site extract chain
(`extract_if_condition_with_card_name` → `try_extract_zone_change_object_filter_condition`
→ `parse_zone_change_object_filter_condition` → the gate). The production caller
reuses the existing `extract_trigger_subject_for_context` result (hoisted above
the extract call; the intervening window is ctx-inert and the call is
diagnostics-neutral, so the hoist is behavior-preserving). The gate becomes a
3-arm match:
- `Some(SelfRef)` → defer to the `parse_inner_condition` Source-binding path (CR 201.5);
- `Some(_other)` → bind the dying event object (CR 603.6c leaves-battlefield + CR 603.10a look-back);
- `None` (the `#[cfg(test)]` wrapper only) → keep the retired proxy as a behavior-identical fallback.

Also corrects a CR miscite this gate has carried since BB4: the dying gate cited
CR 603.6a (enters-the-battlefield) — replaced with CR 603.6c (leaves-the-
battlefield) + CR 201.5 (self-reference binds to source) + CR 603.10a (zone-change
look-back for the past-tense "was" snapshot) + CR 603.4 (intervening-if). The
ENTERING sibling's 603.6a is correct and left untouched.

Verification: whole-corpus card-data BYTE-IDENTICAL (MD5 match, 0 of 35396 cards
differ) — a representation-stable refactor; all three real dying-PT cards
(Deathknell Berserker / Massacre Girl / Jason Bright) unchanged; GAINED=0,
REGRESSED=0. The new synthetic other-death-POSTFIX test drives `parse_oracle_text`
end-to-end (the production `Some` path, not the `None` wrapper) and is
revert-probe discriminating: reverting the threading flips it from
`ZoneChangeObjectMatchesFilter{power>=3}` (event object) to
`QuantityComparison{Power(Source)}` — measured red pre-fix, green post-fix.
Massacre runtime + snapshot tests green; `cargo check --workspace` green.

CR 603.6c / CR 201.5 / CR 603.10a / CR 603.4.

Assisted-by: ClaudeCode:claude-opus-4.8
…it check_swallowed_clauses

phase-rs#5668 ("per unit swallow audit") changed `check_swallowed_clauses` from the 3-arg
`(oracle_text, parsed, diagnostics)` form to a 5-arg `pub(crate)` form taking
lowering-internal `items`/`tracks` that a mutated `ParsedAbilities` cannot supply.
The rebase is otherwise clean — every L02 exemption lands in detector-body regions
phase-rs#5668 left intact — so only BB8's three direct-call tests needed adaptation:

- `valgavoth_alt_cost_marker_is_load_bearing` + `maximum_carnage_goadall_marker_is_load_bearing`:
  retarget to the unchanged private `detect_condition_if` (the detector the markers
  live in), preserving the mutate->re-fire non-vacuity proof in BOTH directions —
  with the marker mutation the Condition_If swallow re-fires; on the un-mutated
  baseline it is suppressed. Both cards are coverage-supported (zero
  `Effect::Unimplemented`), so phase-rs#5668's per-unit unimplemented gate is a genuine
  no-op and the detector-level assertion equals production behavior.
- `valgavoth_alt_cost_does_not_hide_unrelated_if`: re-expressed via phase-rs#5668's own
  `conditional_enter_counters_does_not_hide_unrelated_if` template — through-parse
  the card plus an unrelated, genuinely-unrepresented "if" clause and assert it
  still swallow-flags, proving the alt-cost marker doesn't over-suppress.

No exemption logic changed; phase-rs#5668's audit rewrite (swallow_check.rs + oracle_ir/feature.rs)
is fully preserved.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray force-pushed the feat/std-l02-condition branch from 348a059 to 411e6d7 Compare July 12, 2026 16:36
@lgray

lgray commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks for the detailed review — every point measured and addressed. Rebased onto #5668 (fa7f157fa); all figures below are re-measured against your per-unit audit, and the parse-diff population re-confirmed identical after the rebase.

B1 — population (69 cards / 66 signatures)

Fully accounted: ~30 named targets + 39 legitimate class-collateral, dominated by two deliberate class-wide rules migrations — 28 "entered this turn" counts moved to the departure-surviving BattlefieldEntriesThisTurn ledger (CR 608.2i) and 9 "make a token, put a counter on it" anaphors now binding to the token (BB-FU5) — plus 3 representation-only zone serializations. Every */REMOVED pairs with an upgraded typed replacement (no capability loss). A full categorized card-by-card appendix is now in the PR description.

B2 — the two capability removals (measured against the true pre-BB3 parent 313374b0b^)

Both are misparse cleanups, not finality regressions:

  • Intrepid Paleontologist & Leonardo, Sewer Samurai — at base the cast-permission was entirely swallowed (static_abilities: []) and the only parse was a garbage self-ETB Moved replacement PutCounter{target: SelfRef, counter_type: "<the whole clause text>"}. Driven at runtime, that counter lands on the source card itself, no cast-permission is granted, and killing the source sends it to the graveyard, not exile — inert on both axes. BB3 replaces it with the correct ExileCastPermission/GraveyardCastPermission{enters_with_counter: finality} and no replacement.
  • The ability/PayCost removal is Blasphemous Edict (not Noctis — Noctis only gained extra_cost: additional). Its "pay {B} rather than this spell's mana cost if there are thirteen or more creatures" is an alternative cost, misparsed at base as a standalone PayCost ability; BB7 re-models it as a proper AlternativeCost casting option gated on ObjectCount(Creature) ≥ 13, with parse + runtime tests. Same cost, correct representation — nothing lost.

On finality: CR 122.1h die→exile enforcement does not exist anywhere in the engine (only counter-placement), so no representation old or new exiled a dying finality-countered permanent. The real fix is a tracked follow-up (class-wide CR 122.1h death→exile, mirroring shield counters CR 122.1c). This lane correctly scopes to counter placement.

B3 — "condition attached twice" (X ∧ X)

A display artifact, not a duplicate condition. Fearless Swashbuckler parses to And[AttackersDeclaredCount{Pirate}≥1, AttackersDeclaredCount{Vehicle}≥1] — a faithful Pirate ∧ Vehicle parse; the two conjuncts differ only in their subject.filter. The parse-diff overlay collapsed them to identical text because the condition formatter (coverage.rs) elides the subject field via ... The attach site (parse_typed_attacked_this_combat_conjunction) is single-pass and a whole-DB scan finds zero byte-identical duplicate conjuncts. A dedup here would be incorrect — it would make the card fire on a lone Pirate. We've filed the lossy formatter as a tracked coverage-tooling follow-up so the overlay renders the subject filter; the parse itself is correct and unchanged.

Verify-flagged cards (drove-and-measured)

  • Lupine Prototype & Naktamun Lorespinner ("a player has ≤N cards") — not a scope bug. The predicate reads each candidate player's own hand (the player field on HandSize is a documented-inert placeholder ignored by the evaluator). Measured existential: TRUE when any opponent has ≤N cards while the controller has more, FALSE when all players are above. The {player: Controller} in the overlay is the diff tool's Rust-Debug render, not a serialization defect.
  • Happily Ever After (the ability/there non-blocking note) — good eye; this is a pre-existing there sentence-fragment misparse the lane surfaced but didn't create. The card is coverage-unsupported (its win effect parses to Unimplemented), so it's excluded from real games and never resolves a win today. We measured the forced resolution: the WinTheGame sub would fire on the partial 5-colors gate, so we've filed a tracked follow-up that must rebuild the full three-conjunct condition and hoist the trigger-level intervening-if before there is implemented — implementing the fragment alone would turn it into a live eager-win. Not fixed in this lane (no real-game behavior change; unsupported before and after).

Size

Acknowledged — the lane bundled 8 building blocks + 5 in-branch follow-ups. Future condition lanes will split at the building-block boundary to keep review units smaller.


No code change was needed for any blocking item (all measured-correct / cleanup / false-positive); the only new commit on the rebase is a test-only adaptation of BB8's three swallow-audit tests to your per-unit check_swallowed_clauses signature. Full CI-equiv green at 411e6d7dd (clippy 0-warnings, nextest 21046/0, coverage-regression engine/honesty/oracle-changed all 0, +34 supported). Happy to expand any of these.

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

Verdict: changes requested — the finality-counter lane is rules-incomplete and coverage-unsafe.

🔴 Blocker

  • Finality is serialized as an inert generic counter, not enforced as CR 122.1h's replacement effect. Evidence: crates/engine/src/types/counter.rs:64 represents it as Generic(String); crates/engine/src/game/casting_costs.rs:6901 only adds the pending ETB counter; and crates/engine/src/game/replacement.rs:5286 synthesizes intrinsic counter replacements only for shield counters, with no battlefield-to-graveyard finality candidate. The new integration suite explicitly limits itself to placement, “never death→exile,” at crates/engine/tests/integration/l02_bb3_cast_permission_finality.rs:16. Intrepid Paleontologist's Oracle text says: “If a creature with a finality counter on it would die, exile it instead.” CR 122.1h defines the replacement as: “If this permanent would be put into a graveyard from the battlefield, exile it instead.” A finality-countered creature therefore still reaches the graveyard after destruction, sacrifice, or an SBA in this branch.

    Recommendation: route this through the full engine cycle before merging. Add the typed finality-counter/replacement building block at the shared zone-change replacement seam (covering destroy, sacrifice, and SBA), with discriminating runtime tests for each; otherwise preserve strict unsupported coverage for these cards instead of clearing their condition warnings.

🟡 Non-blocking

  • No additional current-head design finding. The maintainer merge advanced the head to c49e6a8; its CI and parse-diff are correctly pending and must be fresh before the next review.

✅ Clean

  • The earlier apparent duplicate Fearless Swashbuckler condition is a formatter loss, not a duplicated parse: crates/engine/src/parser/oracle_trigger.rs:4089 retains distinct Pirate and Vehicle filters, while crates/engine/src/game/coverage.rs:3907 deliberately formats only comparator/count.
  • The prior Moved and PayCost observations were rechecked against the current implementation: the cast-permission/counter plumbing is at the correct selected-permission seam, and Blasphemous Edict's printed “pay {B} rather than pay this spell's mana cost” is an alternative-cost shape. The remaining defect is the missing generic finality rule, not those representation changes.

Recommendation: keep this PR in requested-changes state; regenerate the parse-diff and rerun current-head CI after the rules-complete finality implementation.

@matthewevans matthewevans removed their assignment Jul 12, 2026
@matthewevans matthewevans added the feature Larger-scoped feature label Jul 12, 2026
@matthewevans matthewevans self-assigned this Jul 12, 2026
…iling

Adds finality_counter_death_to_exile.rs: one test per distinct death path
(destroy, sacrifice, SBA lethal damage, SBA zero-toughness), each driving the
real pipeline (.resolve() / check_state_based_actions) and asserting the finality
permanent lands in exile, not the graveyard. Each carries a negative twin (no
finality counter -> graveyard) as the reach-guard proving the driver actually
kills, plus a dies-trigger differential on the cast + SBA delivery classes
(CR 700.4 -- the redirect is a true "instead", not graveyard-then-exile).
Revert-probe: neutralizing the injection arm flips all four positives
Exile->Graveyard.

Flips the l02_bb3 finality suite's placement-only ceiling (the "never
death->exile" comment) now that CR 122.1h is enforced.

CR 122.1h + CR 700.4 + CR 400.7.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Implemented the full CR 122.1h finality death→exile replacement (Option A) — finality counters now redirect a dying permanent to exile instead of the graveyard, not just get placed. Pushed as 3 commits on top of your merge c49e6a8e9 (preserved as ancestor; no rebase/squash across it).

Architecture. It mirrors the shield-counter intrinsic replacement, but on the correct seam: shield replaces ProposedEvent::Destroy, whereas CR 122.1h is a zone-change redirect, so finality replaces ProposedEvent::ZoneChange{from:Battlefield, to:Graveyard}. That single virtual candidate catches battlefield→graveyard death in one place — destroy, sacrifice, SBA lethal damage, SBA zero-toughness, and the other move_object SBA paths — because they all converge on that event. It rides the same replacement seam as shield counters and Rest in Peace (both shipped + tested), so it intercepts every battlefield→graveyard death those already do; the SBA zone-change consult (ZoneChangeCause::is_exempt) is exhaustive with no death cause exempt, and we measured the four principal paths end-to-end (each with a revert-probe). The applier redirects to exile via Ok(Modified) (never Prevented) and does not consume the counter — CR 122.1h, unlike shield CR 122.1c, has no remove-counter clause. One-shot is guaranteed by the existing already_applied guard plus the to:Graveyard match (the post-redirect to:Exile no longer matches).

Design calls. Promoted the counter to a first-class typed CounterType::Finality (matching the Shield/Stun/Defense precedent + the enum-over-stringly-typed guideline); serialization is byte-identical to "finality", so there's no card-data migration and the change is parse-neutral. CR 616 materiality is Unconditional, consistent with every existing zone-redirect (Rest in Peace, shield-destroy, umbra) — a lone finality counter auto-applies, and co-firing redirects force the CR 616.1e ordering choice; we deliberately did not add a new EventField::Destination axis (no candidate ever writes a destination field, so it would be dead flexibility). No user toggle — this enforces a printed, previously-inert ability, not a new optional feature.

Tests. Four discriminating integration tests, one per distinct death path (destroy / sacrifice / SBA-lethal-damage / SBA-zero-toughness), each driving the real pipeline and asserting exile-not-graveyard, each with a negative twin (no counter → graveyard, the reach-guard proving the driver actually kills) and a dies-trigger differential proving true "instead" (CR 700.4 — the exiled permanent never reaches the graveyard, so dies triggers don't fire). Revert-probe: neutralizing the injection arm flips all four positives Exile→Graveyard. One measured refinement over the original plan: we dropped a "counter survives on the exiled permanent" assertion because CR 400.7 makes the exiled object a new object with no counters — non-consumption is instead guaranteed structurally (the applier takes no &mut GameState, so it physically cannot decrement a counter).

CI is green (clippy --workspace --all-targets 0 warnings, nextest 21051/0 incl. the 4 new tests, Gate A/B, coverage REGRESSED(engine)=0, oracle-gen identical to pre-finality). The change adds zero production parser lines, so card-data is unchanged. (Three REGRESSED(honesty) cards on the current run — Avatar Destiny / Machinist's Arsenal / The Doctor's Childhood Barn — are stale-base false positives from being a few commits behind main's parser fixes #5688/#5689; the finality diff has no parser edits so it can't affect them, and they clear on the next merge-forward.)

matthewevans and others added 2 commits July 12, 2026 15:15
Port typed swallow evidence with cast-permission rider coverage.

Co-authored-by: Lindsey Gray <lindsey.gray@gmail.com>
@matthewevans matthewevans removed their assignment Jul 12, 2026
@matthewevans matthewevans self-assigned this Jul 13, 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.

Approved after resolving the remaining finality runtime, visibility, and parser-audit feedback; current-main CI is green.

@matthewevans
matthewevans added this pull request to the merge queue Jul 13, 2026
@matthewevans matthewevans removed their assignment Jul 13, 2026
Merged via the queue into phase-rs:main with commit 57cdd5c Jul 13, 2026
12 checks passed
matthewevans added a commit that referenced this pull request Jul 13, 2026
Two PlayerFilter imports lost their last user when #5677's HasCounters
arm replaced the bespoke lowering, and the describe() test helper lost
its callers when SharedRestrictionParse::Unsupported dropped its payload.
Local 'cargo test' passes with warnings; CI's -D warnings does not.
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 13, 2026
* refactor(parser): make parse_inner_condition the primary restriction grammar

CR 601.3 / CR 602.5: a casting/activation restriction is an ordinary game-state
condition, so it must be recognized by the same combinators that recognize the
identical phrase in an "as long as" static. Invert the ordering:
parse_restriction_condition now runs all_consuming(parse_inner_condition) FIRST
and converts, and the bespoke restriction grammar is a narrow fallback.

- Tri-state SharedRestrictionParse {NoMatch, Converted, Unsupported}. A phrase the
  shared grammar UNDERSTOOD but ParsedCondition cannot hold exactly fails the parse
  outright; it is never re-read by the fallback, which would produce a weaker
  reading of the same words.
- static_condition_to_restriction_condition is exhaustive over all 57 StaticCondition
  variants with no wildcard arm, so a new variant is a compile error rather than a
  silently permissive restriction. Counter bands are rejected, never widened into an
  "at least" that drops the maximum (CR 122.1 / CR 711.2a).
- Compound conditions move into parse_inner_condition as one parameterized connective
  combinator; the legacy " and "/" or " raw-string splitting is deleted. A string split
  cannot see a connector sitting INSIDE an atomic leaf; requiring both sides to parse
  as complete conditions is what makes the decomposition safe. n-ary chains nest
  instead of leaving an unconsumed — and silently swallowed — tail.
- CR 903.3d: "you control a commander" converts to an ObjectCount over the IsCommander
  filter. It previously became subtype "commander" — a subtype no permanent has — so
  Deflecting Swat's free-cast condition could never be satisfied.
- An unparsed condition must never be stored as RequiresCondition { condition: None }.
  That evaluates permissively (Option::is_none_or -> true) AFTER consuming the source
  clause, so the ability reported as fully supported while being activatable in exactly
  the situations the card forbids. All six activation sites now route through one
  atomic commit_requires_condition authority that mutates nothing on failure, leaving
  the clause for the ordinary Effect::Unimplemented fallback.

Full-pool dual export (population 35,396 faces): RequiresCondition{condition: null}
goes 50 occurrences / 48 faces -> 0. 42 faces become honestly unsupported: 35 were
that permissive lie (no restriction enforced at all), 3 were confidently-wrong parses
(a bare-subtype dump that could never be satisfied, a QuantityVsEachOpponent comparing
a value to itself, an "attacking or blocking" narrowed to "attacking"), and 4 are true
regressions in two named classes tracked as follow-ups. 1 gain, 15 dead parsers
deleted (-611 lines net).

* test(parser): pin the restriction-only fallback and prove the commit is atomic

Enforcement for the P02-U3 ruling. Both gates were watched failing before being
accepted; reverting the commit order reproduces the original defect verbatim as
[RequiresCondition { condition: None }, OnlyOnceEachTurn].

- restriction_only_fallback_dispatches_exactly_the_pinned_families: a STRUCTURAL pin,
  not prose. The fallback's doc comment tells contributors not to add a phrase there;
  a comment stops nobody. This reads the module's own source, extracts the dispatcher's
  body, and rejects any parse_*(text) call outside the pinned six — each tagged with WHY
  it is retained (restriction-context referent / vocabulary gap / phrasing gap pending
  P02-U3b). Adding a seventh arm turns it red with the question the author must answer:
  is the new phrase referentially special, genuinely inexpressible, or merely spelled
  differently? Only the first two belong there.
- failed_activation_condition_commits_no_cadence_and_leaves_the_clause_unimplemented:
  the witness for commit_requires_condition. An activation gate whose condition fails to
  parse must commit NOTHING — not the cadence restriction sharing the sentence, not a
  permissive RequiresCondition { condition: None }, and not the source text, which must
  survive as Effect::Unimplemented.
- parsed_activation_condition_commits_both_cadence_and_condition: the positive
  reach-guard, so the atomicity test cannot pass merely because the peeling branch never
  ran. It stays green under the revert while the atomicity test goes red.

The source-scanning `.find` calls carry allow-noncombinator annotations: they parse this
module's own bytes, not Oracle text.

Test-only; parse output unchanged.

* fix(parser): drop dead-code fallout from the rebase (CI -D warnings)

Two PlayerFilter imports lost their last user when phase-rs#5677's HasCounters
arm replaced the bespoke lowering, and the describe() test helper lost
its callers when SharedRestrictionParse::Unsupported dropped its payload.
Local 'cargo test' passes with warnings; CI's -D warnings does not.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants