Skip to content

feat(engine): S25b standard tranche — 7 commits, 12 cards + FIN Tiered 7/7#5534

Merged
matthewevans merged 11 commits into
phase-rs:mainfrom
lgray:feat/std-s25b
Jul 11, 2026
Merged

feat(engine): S25b standard tranche — 7 commits, 12 cards + FIN Tiered 7/7#5534
matthewevans merged 11 commits into
phase-rs:mainfrom
lgray:feat/std-s25b

Conversation

@lgray

@lgray lgray commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

S25 Standard-set tranche, part b (s25b). The 7 Group-C Wave-5 subsystem cards deferred from s25a (#5155) — each landed through the full /engine-implementer pipeline (plan → review-plan → implement → review-impl → commit), one card at a time, strictly serial. Seven commits flip 12 cards to supported=true, complete the FIN Tiered class (7/7), and add two class-level correctness fixes. Branch rebased onto current upstream/main (ffbfc916c); the AnyTurn / recipient / extra_source enum surfaces re-verified against the rebased base (details in Verification).

Each card is a building block, not a one-off:

# Commit Card(s) flipped supported=false→true Building block
1 192e92f7e Vanille, Cheerful l'Cie + Titania, Voice of Gaea (+ Urza, Lord Protector result-string repair) meld triggers with a leading intervening-if condition + an inserted optional cost; meld-result name mask (parser-only, meld subsystem reuse)
2 8920879a3 Moonlit Meditation token-creation replacement + a per-source once-per-turn latch (ReplacementCondition::FirstTokenCreationEachTurn, CR 614.4), substituting CopyTokenOf{AttachedTo}
3 b10361bbf Niko, Light of Hope + Absorb Identity + Deceiver of Form mass become-copy: parameterize Effect::BecomeCopy with a recipient set + a turn-agnostic PlayerScope::AnyTurn end-step expiry (CR 611.2c locked set)
4 e1e9a66ef No Witnesses + Tectonic Hellion "each player who controls the most <type>" superlative (ControlsCount + ControlledByEachPlayer{Max}) + Effect::Investigate
5 951327958 Vincent's Limit Break (completes FIN Tiered 7/7) distribute a Tiered shared-effect line into chosen-P/T modes; fixes a live silent-drop of the chosen base P/T (layer 7b set)
6 1de202e98 Graceful Takedown + Friendly Rivalry compound multi-source each-deal-power damage: parameterize EachDealsDamageEqualToPower with an optional distinct second source group (extra_source)
7 043095b11 Glen Elendra's Answer counter all opponent spells and abilities on the stack (one merged CounterAll{Or[..]}) + a class-level count fix so "for each spell and ability countered this way" counts countered abilities (they emit no ZoneChanged)

Full per-card rationale + CR annotations live in the individual commit messages.

Implementation method (required)

  • Produced via the /engine-implementer pipeline (plan → review-plan → implement → review-impl → commit)

Per card, strictly serial (one sub-agent at a time; shared parser/type files collide). One rebase-adaptation commit on top reconciles the drift main's +49 advance introduced — documented in that commit: two new upstream Effect::BecomeCopy construction sites threaded with the Niko recipient field (serde-default SelfRef), a HashSet path qualification, and the ReplacementId → AppliedReplacementKey applied-set key refactor in the Moonlit test (the resolver auto-merged to the new key; AppliedReplacementKey::object(source, index) is the semantic equivalent, self-suppression re-verified). The tests/integration/main.rs mod-line reconciliation was folded into the Niko commit at rebase.

Files changed

  • crates/engine/src/types/ability.rsEffect::BecomeCopy.recipient, PlayerScope::AnyTurn, EachDealsDamageEqualToPower.extra_source, ReplacementCondition::FirstTokenCreationEachTurn (all serde-defaulted → existing card-data byte-identical).
  • crates/engine/src/parser/… — nom combinators: meld leading-condition/optional-cost, the "most <type>" superlative, the Tiered shared-effect distributor, the compound each-deals source, the counter-all conjunction handler.
  • crates/engine/src/game/… — resolvers/targeting/layers/replacement + the counter-for-each SpellCountered count arm; exhaustive-match fan-out (incl. PlayerScope::AnyTurn across ability_rw/ability_scan/coverage/quantity/effects).
  • crates/engine/tests/integration/… — one discriminating test file per card (revert-to-red verified).
  • crates/mtgish-import, crates/phase-ai — exhaustive-match / consumer threading for the new engine surfaces.

CR references

Union of the grep-verified CR annotations across the 7 commits: CR 107.1, 109.4, 109.5, 110.5d, 111.2, 111.10f, 113.9, 115.1d, 115.3, 115.4, 118.12, 120.1, 201.2a, 201.5c, 205.4a, 208.1, 303.4, 405.1, 513.1, 601.2c, 601.2f, 603.4, 603.7b, 608.2c, 608.2h, 611.2a, 611.2b, 611.2c, 613.4b, 613.4c, 614.1a, 614.4, 614.6, 616.1, 700.2, 701.16a, 701.42a, 701.6a, 702.183a, 704.5e, 707.2. See per-commit CR annotations for the exact rule→code mapping.

Track

Developer

LLM

Model: claude-opus-4.8
Thinking: high–max (planners/reviewers at xhigh; implementers/impl-reviewers at high)

Verification

Measured in the worktree (cargo direct — this branch is not Tilt-watched).

Base-timing note (read before the table): upstream/main advanced +3 (bfe318f73ffbfc916c) mid-gate. The behavioral gates below (clippy, test -p engine, FORGE, coverage) were measured on the +49 base bfe318f73. The branch was then re-rebased onto the final base ffbfc916c — a clean 0-conflict rebase that adds no enum variants — and cargo check --workspace --all-targets was re-verified EXIT 0 on that final base. FORGE delta-vs-main and coverage are base-relative numbers; because ffbfc916c introduces no engine-types/ evolution, they are expected to carry over, and CI runs the full suite on the final base ffbfc916c as the authoritative head-SHA measurement.

  • cargo fmt --all -- --checkEXIT 0 (no diffs)
  • cargo check --workspace --all-targetsEXIT 0 (re-run on the final base ffbfc916c; also confirms phase-ai + mtgish-import exhaustive arms compile)
  • cargo clippy --workspace --all-targets -- -D warningsEXIT 0 (0 warnings)
  • ./scripts/check-parser-combinators.shEXIT 0
  • ./scripts/check-engine-authorities.shEXIT 0
  • cargo test -p engine (lib + integration) — 18730 passed / 0 failed (16071 lib + 2644 integration + 6 + 9; 15 ignored)
  • FORGE_TEST_FULL_DB=1 cargo test -p engine ordering_parity_sweepswept=13598, compared=6218, unexplained=20, delta-vs-main = 0. The 20 unexplained rows are exactly the pre-existing hidden/veiled/opal/lurking morph cycle (Urza's Saga block); zero s25b cards appear in the unexplained set or anywhere in the sweep. The opt-in full-DB run always surfaces these 20 (strict gate is CI-fixture-scoped); the acceptance criterion is delta-vs-main=0, met.
  • cargo coverageEXIT 0 on a corpus regenerated from HEAD (offline, cached MTGJSON — no network): supported_cards 31510 / 35396 (89.02%). All 12 GAINED cards measured Unimplemented=0 on the fresh corpus (incl. Glen's merged CounterAll{Or[StackSpell, StackAbility]} — the pre-fix split CounterAll(spells)+Unimplemented("abilities") is gone). REGRESSED = 0 (the ~24-card EachDealsDamageEqualToPower family, the counter-for-each family {Swift Silence}, and every existing BecomeCopy/replacement/counter card byte-identical; per-card bounded-blast probes confirmed no sibling regressions). CI's coverage-regression check is the full-DB backstop confirming the exact GAINED set + REGRESSED=0 vs the PR base.

Post-rebase enum-surface re-verification: PlayerScope::AnyTurn (Niko) remains threaded through every exhaustive PlayerScope match site after the +49 rebase (rw_player_scope→Controller-identical profile, scanAxes::NONE, coverage→display, quantityunreachable! (duration-only), effects→controller, layers prune), with the sole production construction still the "the next end step" parser arm. FORGE ordering-parity delta = 0 (measured on the +49 base bfe318f73; see the base-timing note above — CI re-measures on the final base ffbfc916c).

Scope Expansion

This tranche parameterizes existing effects (BecomeCopy.recipient, EachDealsDamageEqualToPower.extra_source), adds one duration-timing PlayerScope::AnyTurn and one ReplacementCondition, completes the FIN Tiered mechanic, and lands two class-level correctness fixes (the counter-for-each ability count; the Tiered chosen-base-P/T silent-drop). No new subsystems — the heavy mechanics (meld, Tiered, mass counter, per-source power damage, Investigate) all pre-existed and were reused. Maintainer review invited.

Validation Failures

None.

Predecessor

Continues s25a — #5155 (the "Deferred to s25b" table there is exactly these 7 cards).

lgray added 8 commits July 10, 2026 18:14
…costs

Rewrite parse_meld_gate to handle two meld-instigator grammar axes it
previously rejected, and protect meld result names during self-reference
normalization. A measured class fix: Vanille, Cheerful l'Cie and Titania,
Voice of Gaea flip supported=false->true, and Urza, Lord Protector's
corrupted result string is repaired.

- Leading intervening-if condition ("if there are four or more land cards
  in your graveyard and you both own and control ...") is parsed via
  parse_inner_condition and prepended to the trigger's And (CR 603.4).
  Unlocks Titania.
- Optional-cost reflexive form ("... you may pay {C}. If you do, exile
  them, then meld them into R"): delete the period-guard that deferred it
  and reshape the gate residual so the existing reflexive PayCost /
  OptionalEffectPerformed machinery gates the meld (CR 118.12). Unlocks
  Vanille. Declining the payment must not meld.
- Guarded per-clause meld dispatch (parser/oracle_effect/mod.rs): the
  reflexive meld sub-clause is a single chunk ("meld" is in no verb table)
  that bypassed the pre-chunk meld dispatch; route it through the sole
  authority parse_meld_effect_clause (CR 701.42a). pending_meld_partner is
  threaded into chunk_ctx.
- Meld-result name mask (parser/oracle_util.rs): the name after "meld them
  into" is a distinct object (CR 201.2a), not a shortened self-reference
  (CR 201.5c). Mask it during normalization so a result sharing a token
  with the card's comma-short name is not folded to "~, ..." -- fixes the
  measured live corruption (Titania -> "~, Gaea Incarnate", Urza ->
  "~, Planeswalker") that made perform_meld miss the card_face registry
  and no-op. Class-level: protects every meld result uniformly.

The meld Effect, resolver, targeting, and multiplayer filter are unchanged;
this is a parser-only extension with no new engine variant.

Tests: a runtime accept/decline discrimination test (pay => meld, decline
=> no meld, CR 118.12) driving the production resolve/act path; parse
round-trips asserting the meld is the OptionalEffectPerformed-gated child
of the PayCost; Titania's 3-conjunct And with the >=4-land threshold; a
mask unit test; and regression guards keeping the 5 clean meld cards
(Urza, Mightstone, Argoth, Gisela, Bruna) supported. Also fixes a
pre-existing vacuous find_meld helper in database/meld_tests.rs that only
inspected top-level effects (it silently passed a stale deferral test).

Assisted-by: ClaudeCode:claude-opus-4.8
…ent (Moonlit Meditation)

Implement Moonlit Meditation: "The first time you would create one or more
tokens each turn, you may instead create that many tokens that are copies of
enchanted permanent." (supported=false->true). A class extension of the
existing token-creation replacement subsystem, not a new subsystem.

- New ReplacementCondition::FirstTokenCreationEachTurn { player: ControllerRef }
  -- a per-source, from-existence once-per-turn gate. Per CR 614.4 (a
  replacement can't act on an event that occurred before it existed), this
  tracks whether THIS source's replacement has applied this turn, not a global
  "any token this turn" latch: a Moonlit entering mid-turn after an earlier
  creation still fires on the next creation. Reuses the per-ObjectId
  once-per-turn pattern of crew_activated_this_turn via a new
  GameState.first_token_replacement_used_this_turn set, consumed at mark_applied
  (on accept AND decline -- declining still spends "the first time"), cleared at
  turn start.
- The substitute is Effect::CopyTokenOf { target: AttachedTo } (the Springheart
  Nantuko building block): copies of the Aura's enchanted host, count = "that
  many" via QuantityRef::EventContextAmount.
- Owner scope via token_owner_scope(You) (CR 111.2 / 109.5), NOT valid_card -- a
  CreateToken event has no affected_object_id, so a valid_card gate is
  unsatisfiable and would make the replacement never fire.
- Self-suppression: the CopyTokenOf continuation re-enters the replacement
  pipeline (the phase-rs#1511 doubler-visibility path); it now inherits the
  post_replacement_token_choice_applied set carrying its own ReplacementId, so
  Moonlit self-suppresses on its own copies while a doubler (different id) still
  doubles them.
- Count provenance: a transient GameState.post_replacement_token_substitution_count
  stamped at the copy-substitution seed gate and read first in the
  EventContextAmount cascade -- the accept path zeros the original count, and
  EventContextAmount could otherwise be shadowed by an unrelated trigger-event
  amount. Both transient continuation seeds are also scrubbed at turn start.

CR 614.1a / 614.4 / 614.6 / 616.1 / 111.2 / 109.5, all grep-verified.

Tests (crates/engine/tests/integration/std_longtail_e.rs): accept creates
copies of the host; opponent-owned creation is ignored (owner scope); a Moonlit
entering mid-turn fires on the next creation (per-source, not global); "that
many" = N (not 0, not shadowed); decline consumes the turn window; latch resets
next turn; two Moonlits are independent; Doubling Season yields exactly two
host-copies with no recursion (phase-rs#1511 doubler + self-suppression); parse
round-trip; and a turn-start transient-seed scrub guard. Jinnie Fay / Divine
Visitation / doublers regression-verified unchanged.

Assisted-by: ClaudeCode:claude-opus-4.8
Implement Niko, Light of Hope's middle clause "Shards you control become
copies of it until the next end step" (supported=false->true). A class
extension of Effect::BecomeCopy, not a new subsystem -- the same change also
flips Absorb Identity and Deceiver of Form (each "[filter] you control become
copies of ..."), a build-for-the-class win.

- Parameterize Effect::BecomeCopy with recipient: TargetFilter (default
  SelfRef, serde-skipped when SelfRef so every existing single-subject card is
  byte-identical), mirroring GainActivatedAbilitiesOfTarget.recipient. SelfRef
  = the source ~ (all prior cards, exact prior behavior). A typed group filter
  or ParentTarget selects a mass recipient set, resolved to concrete object ids
  at resolution and locked (CR 611.2c) -- one transient continuous effect per
  id keyed SpecificObject{id}, mirroring the gain-activated-abilities resolver.
- New PlayerScope::AnyTurn: a turn-AGNOSTIC end-step deadline for the definite
  article "the next end step" (the next end step to begin, whoever's turn),
  distinct from Controller for the possessive "your next end step"
  (Rocco/Street Chef). Mirrors the delayed-trigger split AtNextPhase (agnostic)
  vs AtNextPhaseForPlayer (scoped). Niko's copy expiry co-fires with the
  return's agnostic AtNextPhase{End} on the same PhaseChanged{End} -- correct
  even for instant-speed activation on an opponent's turn (the ability has no
  sorcery restriction, CR 602.2), where a controller-scoped duration would
  wrongly persist the copies a full rotation past the return.
- Extend the existing prune_until_next_end_step_effects additively with one
  ungated AnyTurn case; the {End, Controller} controller-scoped case is
  textually unchanged, so Rocco/Street Chef stay byte-behavior-identical.
- Donor "it" = the exiled creature (ParentTarget); copiable values are read
  from exile (CR 707.2, no zone gate -- ObjectId is preserved across exile) and
  snapshotted once into the copy modification (CR 707.2b).
- RW ordering profile: the mass path writes the recipient scope and reads the
  donor (CR 707.2 / 611.2c / 603.3b); the SelfRef path keeps the exact prior
  write-scope, so the existing copy corpus is byte-identical and FORGE
  ordering-parity delta is 0.

CR 110.5d / 513.1 / 603.7b / 611.2a / 611.2b / 611.2c / 707.2, all grep-verified.

Tests (crates/engine/tests/integration/niko_light_of_hope.rs): parser
round-trip (middle clause lowers to BecomeCopy{recipient=Typed(Shard,You),
target=ParentTarget, dur=UntilNextStepOf{End,AnyTurn}}, chained between exile
and return, zero Unimplemented); locked recipient set (a Shard created after
resolution is NOT copied, CR 611.2c); copies are of the exiled donor not Niko
(a 5/5 donor != Niko's 3/4); opponent-turn co-fire (Shards revert AND the
creature returns at the opponent's end step, not a turn later -- fails under
Controller scope) with a your-turn reach-guard; recipient is not a target slot;
single-subject byte-identity; the class covers Absorb Identity + Deceiver of
Form; and a Gate-1 profile test that AnyTurn walks the RW duration path without
panicking. Revert-probes: AnyTurn->Controller fails the opponent-turn test
while the your-turn guard passes; recipient->SelfRef fails 6 of 7 core tests.

ponytail: mass become-copy with enter-as-copy counter exceptions ("except they
enter with a +1/+1 counter") is not a printed card class; those resolution-time
mods stay scoped to the single-subject path (become_copy.rs:175), a documented
ceiling until such a card appears.

Assisted-by: ClaudeCode:claude-opus-4.8
…ative (No Witnesses)

Implement No Witnesses' first clause "Each player who controls the most
creatures investigates" (supported=false->true). A class-wide parser fix, not a
card special-case: the same superlative arm also flips Tectonic Hellion ("each
player who controls the most lands sacrifices two lands of their choice").

- One arm in parse_controls_permanent_object (oracle_effect/lower.rs) recognizes
  "who controls the most <type>" and lowers to ControlsCount{ relation: All,
  filter: <bare Typed>, comparator: GE, count: Ref(ControlledByEachPlayer{ <bare
  Typed>, Max }) } -- reusing the existing cross-player extremum (Balance's
  building block) composed into the existing player-scope. "investigates"
  deconjugates to the already-wired Effect::Investigate; the "Then destroy all
  creatures" sibling detaches as the once-run UNCONDITIONAL tail (the wrath is
  unscoped -- verified structurally and at runtime).
- GE vs the global Max is equivalent to EQ (CR 107.1 integers: no player's count
  exceeds the max) and selects exactly the tied-for-most set; all-equal
  (including all-at-0) means everyone, matching the Tectonic Hellion "if everyone
  controls the same number of lands, everyone sacrifices two lands" ruling.
- The filter stays BARE (controller-less) on both the extremum and the
  player-scope: the resolvers (player_control_count_compares,
  ControlledByEachPlayer) apply the per-player controller gate themselves, so a
  .controller(You) here would be redundant. Honest-red guard: a type phrase that
  fails to parse stays Unimplemented rather than building a bogus extremum.
- No new engine variant; reuses ControlledByEachPlayer, ControlsCount,
  Effect::Investigate, DestroyAll. Parser-only -- no engine/AI/frontend change.

CR 107.1 / 109.4 / 109.5 / 111.10f / 701.16a, all grep-verified.

Tests (crates/engine/tests/integration/no_witnesses_most_creatures_investigate.rs):
parser round-trip (clause 1 -> ControlsCount{...ControlledByEachPlayer{Max}} +
Effect::Investigate, zero Unimplemented); 2p single-most (only the most-creatures
player gets a Clue); 3p distinct (strict max only); 3p tie (both tied players
investigate); all-at-0 (everyone investigates); the wrath destroys a
non-investigator's creatures unconditionally; and the Tectonic Hellion class
sibling (-> Effect::Sacrifice). Revert-probes prove non-vacuity: neutering the
arm fails all 7 tests; Max->Min mis-selects (6 fail); GE->GT excludes the max
holder (7 fail).

REGRESSED=0: the arm is provably inert on any card lacking "who control(s) the
most " -- a bounded before/after parse over the corpus cards containing that
phrase shows only No Witnesses + Tectonic Hellion change; the rest are
byte-identical.

Assisted-by: ClaudeCode:claude-opus-4.8
…des (Vincent's Limit Break)

Implement Vincent's Limit Break's Tiered mechanic (supported=false->true),
completing the FIN Tiered class (7/7 cards). Fixes TWO gaps in one arm: the
three tier option lines were Unimplemented, and the main effect parsed green but
SILENTLY DROPPED "and has the chosen base power and toughness" (no base-P/T set).

- Root cause (3 Unimplemented): Vincent's has a shared effect line between the
  "Tiered" keyword and the parameter-only bullets, so parse_oracle_block saw the
  effect line (not a bullet) at start+1, collected zero modes, and returned None
  -- the existing Tiered arm never fired and the bullets fell through to
  standalone Unimplemented. Root cause (silent drop): "the chosen base power and
  toughness" has no N/M, so parse_base_pt_mod returned None and the
  SetPower/SetToughness were dropped (the dies-return GrantTrigger, from the
  quoted segment, is a separate additive push and survived).
- Fix (parser-only, oracle_modal.rs): a shared-effect distributor
  (parse_tiered_shared_effect_block + parse_shared_chosen_pt_template) that
  substitutes each tier's literal P/T for the "the chosen base power and
  toughness" anaphor and reuses the existing modal-additional-cost machinery --
  mirroring the established distribute_shared_mode_effect / parse_shared_those_template
  sibling. Each mode lowers through the SAME path that produced the main effect,
  now emitting SetPower/SetToughness alongside the dies-return GrantTrigger under
  one UntilEndOfTurn GenericEffect. The chosen tier's additional cost
  ({0}/{1}/{3}) is charged via the existing compute_modal_total_cost single
  authority.
- The base P/T is a CR 613.4b layer-7b SET (Layer::SetPT, ordered before the 7c
  counter layer): a +1/+1 counter applies on top at 7c (3/2 -> 4/3), matching
  the ruling ("overwrites prior P/T-setting effects; counters still apply"). The
  mode choice is bound at cast and immutable (CR 700.2a).
- No new engine variant, keyword, effect handler, or frontend; reuses SetPower /
  SetToughness, OracleBlockAst::Modal, ModalChoice, GrantTrigger, and
  compute_modal_total_cost. A one-line pub(crate) re-export exposes the existing
  parse_pt_mod building block (grammar is a private module).

CR 702.183a / 700.2 / 601.2f / 613.4b / 613.4c, all grep-verified.

Tests: parser round-trip (Vincent's -> modal{3 modes, costs {0}/{1}/{3}} + each
mode carries SetPower/SetToughness AND the dies-return GrantTrigger, zero
Unimplemented; Restoration Magic, a standard Tiered card, unchanged -- the arm is
anaphor-gated); runtime cast via the real pipeline (Galian Beast {0} -> target
base P/T 3/2; Hellmasker {3} -> 7/2 AND total {4}{B} spent, proving the {3}
additional cost is actually charged; a +1/+1 counter -> 4/3, proving the
7b-before-7c layer order; only {1}{B} available -> Hellmasker unaffordable,
proving the additional cost is a real castability gate). Revert-probes
(empirically confirmed): disabling the arm makes the modal fail to form and
every test fail; skipping the additional cost fails the mana-spent assertion;
Max/wrong-tier binding fails the P/T assertions.

REGRESSED=0: the arm fires only for a Tiered header + the "the chosen base power
and toughness" anaphor (a single corpus card) + all-P/T bullets, returning None
(the identical pre-existing path) for every other card.

Assisted-by: ClaudeCode:claude-opus-4.8
…Takedown + Friendly Rivalry)

Implement the compound-source variant of "N target creatures each deal damage
equal to their power to a target" (supported=false->true for TWO cards). The
honest-deferral gate `is_compound_source_each_power_damage` covered a class of
two -- deleting it un-defers BOTH Graceful Takedown and Friendly Rivalry, which
would otherwise have shipped wrong-but-green (their second source group silently
dropped).

- Graceful Takedown ({1}{G} Sorcery): "Any number of target enchanted creatures
  you control and up to one other target creature you control each deal damage
  equal to their power to target creature you don't control."
- Friendly Rivalry ({R}{G} Instant): "Target creature you control and up to one
  other target legendary creature you control each deal damage equal to their
  power to target creature you don't control."

- Parameterize the existing `Effect::EachDealsDamageEqualToPower` with an
  optional second source group `extra_source: Option<TargetFilter>`
  (#[serde(default, skip_serializing_if="Option::is_none")] -> the ~24 existing
  family cards, e.g. Band Together, are byte-identical). The source-count-agnostic
  resolver `resolve_each_deals_equal_to_power` is reused VERBATIM -- the A union B
  chosen sources arrive as more object targets in the flat `[source.., recipient]`
  vector, each dealing its own power (CR 120.1 / 608.2h). No resolver change, no
  new effect/variant/state-machine.
- Parser (nom): an "any number of" count arm (-> unlimited(0)) and a count-1
  "target" arm (-> fixed(1,1)) for the two group-A cardinalities; a new
  `parse_extra_other_source` combinator for "and up to one other target <filter>
  you control" that REQUIRES the `Another` marker (CR 115.4) so a "one target"
  without "other" is not mis-parsed. Filters compose existing props: EnchantedBy
  (Graceful group A, the attached-Aura predicate CR 303.4), HasSupertype Legendary
  (Friendly Rivalry group B, CR 205.4a), Another + You (both group Bs), Opponent
  (both recipients, CR 109.5).
- Targeting (both slot builders): the group-B slot/spec is inserted BETWEEN the
  group-A source slots and the recipient (recipient stays LAST, so the resolver's
  `split_last` treats group B as a source); it gets its own target instance so
  the `Another` distinctness (CR 115.4) excludes every group-A pick at selection
  time. The RwProfile arm is field-agnostic and UNTOUCHED -> FORGE ordering-parity
  delta = 0 for every card.

CR 115.1d / 115.3 / 115.4 / 205.4a / 303.4 / 601.2c / 109.5 / 120.1 / 208.1 /
608.2h, all grep-verified.

Tests: parser round-trips (both cards -> the compound structure with the exact
per-card filters; zero Unimplemented; the "other" marker AND the Legendary
supertype each non-vacuously asserted -- the Legendary assertion flips to red if
the supertype filter is dropped); runtime cast-pipeline (Graceful: two
aura-enchanted sources + one other each deal their own power, Sigma; group-B-only
when no enchanted sources; slot legality -- non-enchanted not a group-A source,
group B can't repick a group-A creature, recipient must be you-don't-control;
Friendly Rivalry: singular group A + legendary group B, and a non-legendary is
rejected as group B so Sigma = 1+2 = 3 not 1+4 = 5, discriminating the legendary
filter at runtime). Revert-probes: dropping the group-B slot changes Sigma;
reverting the parser leaves Unimplemented; the deleted gate's team-scope
fail-closed (Combo Attack, CR 810) is preserved.

REGRESSED=0: the deleted gate + the new arms are reachable only inside the
verb-tag-gated compound-source parse; a bounded card-data scan confirms EXACTLY
{Graceful Takedown, Friendly Rivalry} flip supported=false->true and every other
card is byte-identical. FORGE ordering_parity_sweep delta=0 (the family cards' RW
profiles are unchanged -- the field-agnostic RwProfile arm is untouched).

Assisted-by: ClaudeCode:claude-opus-4.8
…h (Glen Elendra's Answer)

Implement Glen Elendra's Answer's mass counter (supported=false->true) in two
parts: (1) fold "counter all spells your opponents control and all abilities your
opponents control" into ONE CounterAll over opponent-controlled stack objects;
(2) a class-level correctness fix so "for each spell and ability countered this
way" counts the countered ABILITIES, not just spells.

Card: {2}{U}{U} Instant -- "This spell can't be countered. Counter all spells
your opponents control and all abilities your opponents control. Create a 1/1
blue and black Faerie creature token with flying for each spell and ability
countered this way."

- Root cause: "counter all spells your opponents control" already parsed to
  CounterAll{And[StackSpell, opponent]}, but "and all abilities your opponents
  control" dropped to Unimplemented -- try_split_targeted_compound bisects the
  compound before parse_counter_ast, and its all/each carry-forward is skipped
  because extract_effect_verb registers no "counter" verb.
- Step 1 (parser, nom): a new handler try_parse_counter_all_conjunction
  recognizes "counter all/each <A> and all/each <B> ..." and reparses each
  conjunct through the existing parse_counter_ast, composing ONE
  CounterAll{ Or[ And[StackSpell, Typed{opponent}], StackAbility{opponent} ] } --
  the same shape becomes_target_source_filter builds and the stack matcher
  enforces (spell entries route to the And[StackSpell,..] leg, ability entries to
  the StackAbility leg). One merged counter (not two chained), so the "countered
  this way" set is a single population. Reuses the existing StackAbility
  ability-counter infra (Kadena's Silencer) + the source-count-agnostic
  CounterAll resolver (UNCHANGED -- already counters abilities and emits
  SpellCountered per object). Dispatched before try_split_targeted_compound;
  returns None for single-conjunct text (Kadena / Summary Dismissal / Grimoire
  Thief stay byte-identical).
- Step 2 (engine, class-level fix): affected_objects_from_events had no Counter
  arm, so it collected ZoneChanged ids -- but a countered ABILITY leaves the
  stack with no ZoneChanged (CR 405.1: an ability has no card and no zone), so it
  was silently omitted from the count. A dedicated Counter/CounterAll arm reads
  SpellCountered (emitted per countered object, spell or ability), fixing the
  count for the whole counter-for-each family. The arm is EXCLUSIVE (reads only
  SpellCountered, not also ZoneChanged), so a countered spell -- which emits both
  -- contributes exactly one id (no double-count).

CR 113.9 (abilities on the stack are counterable only by effects that
specifically counter abilities) / 405.1 / 608.2c / 701.6a / 704.5e, all
grep-verified.

Tests: parser round-trip (Glen -> CounterAll{Or[And[StackSpell,opponent],
StackAbility{opponent}]} + the Token{TrackedSetSize} sub + the CantBeCountered
static; zero Unimplemented; Kadena / Summary Dismissal / Grimoire Thief
byte-identical). Runtime cast-pipeline: an opponent's spell AND activated ability
are both countered; the Faerie count = spells + abilities countered (the ability
is the discriminator -- revert the count fix and it drops 2->1); a
your-controlled ability is NOT countered; 2 opponent spells -> count 2 not 4
(locks the exclusive-arm no-double-count). Swift Silence -- the existing
counter-for-each card on the changed seam -- is byte-identical: N non-copy spells
-> draws N, and a countered copy + real spell -> draws 2 (copies emit their own
stack->graveyard ZoneChanged before the CR 704.5e cease-to-exist SBA -- the
move guard is is_token, not is_copy -- so copies were ALWAYS counted; this is a
correctness lock, not a Step-2 change). Test of Talents' exile-caused count is
unaffected (Counter members carry no this-way cause).

REGRESSED=0: the parser handler matches only "counter all/each ... and all/each
..." (Glen is the sole corpus match); the counter-for-each family whose tracked
set the Step-2 arm feeds is {Glen Elendra's Answer, Swift Silence} (the other
Counter+TrackedSetSize cards use the Counter as a terminal effect, so their
tracked set is never published for it). FORGE ordering_parity_sweep delta=0 (the
RW profile is unchanged; only the runtime count value changes, which
ordering-parity does not observe).

Assisted-by: ClaudeCode:claude-opus-4.8
…ipient + AppliedReplacementKey

Rebase onto upstream/main (+49) introduced three drift points the +49 commits added after the tranche was authored. Thread the Niko `recipient` field through two NEW upstream `Effect::BecomeCopy` construction sites (serde-default `SelfRef`, so single-subject copy semantics are byte-identical), qualify a `HashSet` path at a new call site (E0433), and migrate the Moonlit test's applied-set literal to upstream's `ReplacementId` -> `AppliedReplacementKey::object` refactor (E0308; object(source, index) is the semantic equivalent, self-suppression re-verified).

- engine_replacement.rs:1145 handle_copy_target_choice: BecomeCopy += recipient: SelfRef

- token_copy.rs:3671 test BecomeCopy += recipient: SelfRef; :560 applied: std::collections::HashSet::new()

- std_longtail_e.rs:1188 ReplacementId{source,index} -> AppliedReplacementKey::object(moonlit, 0)

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray requested a review from matthewevans as a code owner July 10, 2026 23:20
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 10, 2026
@matthewevans matthewevans removed the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 10, 2026
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 14 card(s), 34 signature(s) (baseline: main abc48b5fd5b9)

2 card(s) · ability/multi_source_compound_each_power_damage · removed: multi_source_compound_each_power_damage

Examples: Friendly Rivalry, Graceful Takedown

2 card(s) · ability/the · removed: the

Examples: No Witnesses, Tectonic Hellion

1 card(s) · ability/BecomeCopy · added: BecomeCopy (conditional=revealed is creature, duration=until end of turn, target=parent target)

Examples: Deceiver of Form

1 card(s) · ability/BecomeCopy · added: BecomeCopy (duration=until end of turn, target=parent target)

Examples: Absorb Identity

1 card(s) · ability/BecomeCopy · added: BecomeCopy (duration=until next end step (any turn), target=parent target)

Examples: Niko, Light of Hope

1 card(s) · ability/ChangeZone · added: ChangeZone (kind=activated, target=parent target, timing=sorcery speed, to=exile)

Examples: Urza, Lord Protector

1 card(s) · ability/ChangeZone · removed: ChangeZone (kind=activated, target=parent target, timing=sorcery speed, to=exile)

Examples: Urza, Lord Protector

1 card(s) · ability/CounterAll · field filter: spell on stack + in stack opponentspell on stack + opponent or ability opponent controls on stack

Examples: Glen Elendra's Answer

1 card(s) · replacement/CreateToken · added: CreateToken

Examples: Moonlit Meditation

1 card(s) · ability/EachDealsDamageEqualToPower · added: EachDealsDamageEqualToPower (extra_source=another legendary you control creature, recipient=opponent controls creature, sources=you control creature, targets=1…

Examples: Friendly Rivalry

1 card(s) · ability/EachDealsDamageEqualToPower · added: EachDealsDamageEqualToPower (extra_source=another you control creature, recipient=opponent controls creature, sources=enchanted by self you control creature, t…

Examples: Graceful Takedown

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

Examples: No Witnesses

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

Examples: Vanille, Cheerful l'Cie

1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=land cards in your graveyard ≥ 4 and you control 1+ self + you control you and you control 1+ named "Argoth, Sanctum of…

Examples: Titania, Voice of Gaea

1 card(s) · trigger/Phase · field condition: you control 1+ self + you control you and you control 1+ named "Fang, Fearless l'Cie" you control you control creature

Examples: Vanille, Cheerful l'Cie

1 card(s) · trigger/Phase · field optional: yes

Examples: Vanille, Cheerful l'Cie

1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, constraint=only during your turn, phase=upkeep)

Examples: Titania, Voice of Gaea

1 card(s) · ability/Sacrifice · added: Sacrifice (target=land)

Examples: Tectonic Hellion

1 card(s) · ability/Token · added: Token (token=cards moved× +1/+1 Blue/Black Faerie (Creature Faerie) with Flying)

Examples: Glen Elendra's Answer

1 card(s) · trigger/YouAttack · added: YouAttack (active in=battlefield)

Examples: Mishra, Claimed by Gix

1 card(s) · trigger/YouAttack · removed: YouAttack (active in=battlefield)

Examples: Mishra, Claimed by Gix

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

Examples: Glen Elendra's Answer

1 card(s) · ability/base power 3, base toughness 2, grant trigger · added: base power 3, base toughness 2, grant trigger (affects=parent target, duration=until end of turn, grants=base power 3, grants=base toughness 2, grants=grant tr…

Examples: Vincent's Limit Break

1 card(s) · ability/base power 5, base toughness 2, grant trigger · added: base power 5, base toughness 2, grant trigger (affects=parent target, duration=until end of turn, grants=base power 5, grants=base toughness 2, grants=grant tr…

Examples: Vincent's Limit Break

1 card(s) · ability/base power 7, base toughness 2, grant trigger · added: base power 7, base toughness 2, grant trigger (affects=parent target, duration=until end of turn, grants=base power 7, grants=base toughness 2, grants=grant tr…

Examples: Vincent's Limit Break

… 9 more signature(s) (9 card-changes) — see parse-diff.json
  • 1 card(s) · ability/fearless · removed: fearless
  • 1 card(s) · ability/grant trigger · removed: grant trigger (affects=parent target, duration=until end of turn, grants=grant trigger, target=you control creature)
  • 1 card(s) · ability/have · removed: have (conditional=revealed is creature, duration=until end of turn)
  • 1 card(s) · ability/have · removed: have (duration=until end of turn)
  • 1 card(s) · ability/replacement_structure · removed: replacement_structure
  • 1 card(s) · ability/shards · removed: shards
  • 1 card(s) · ability/{0} · removed: {0}
  • 1 card(s) · ability/{1} · removed: {1}
  • 1 card(s) · ability/{3} · removed: {3}

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.

Decomposed per-mechanic review — each of the 8 mechanics reviewed independently against the Comprehensive Rules and live Scryfall Oracle text. 7 of 8 are clean and independently landable; 1 is blocked on a genuine rules-correctness defect. Recommend splitting the blocked mechanic so the clean work lands now.

🔴 Blocker — Moonlit Meditation token-copy replacement

The "first time each turn" window is keyed to the enchantment's ObjectId (the per-source latch around replacement.rs:4466). That scope is wrong. The card reads "The first time you would create one or more tokens each turn…" — a per-player window — and the card's official ruling makes the failure mode explicit:

If you create one or more tokens, and then Moonlit Meditation comes under your control that same turn, the replacement effect won't apply to any tokens you create for the rest of the turn. You'll have to wait until the next turn.

With a per-source latch, a token created before Moonlit enters mid-turn never consumes the window, so the replacement fires when the ruling says it must not.

The engine already has the correct primitive: players_who_created_token_this_turn (set by record_token_created, reset at turn start, already consumed by ParsedCondition::YouCreatedTokenThisTurn). Gate on that per-player set instead of a new per-ObjectId field — it is both the rules-correct scope and the existing building block, so this resolves the rules bug and the reuse violation together.

Two tests currently enshrine the incorrect semantics and would make the correct fix look like a regression:

  • crates/engine/tests/integration/std_longtail_e.rsmoonlit_fires_from_existence_not_globally (B1)
  • crates/engine/tests/integration/std_longtail_e.rsmoonlit_latch_is_per_object_id (B5)

Also: the CR 614.4 annotation at replacement.rs:4463 is misapplied — 614.4 forbids replacing an already-past event; it does not justify the window reopening when the source enters.

🟡 Non-blocking (fine to ride along with the clean mechanics)

PlayerScope::AnyTurn (types/ability.rs:4711) plants a whose-turn timing referent inside a player-selector enum, forcing two unreachable!() arms in quantity.rs. It's contained and fail-loud, but it conflates two semantic layers — the AtNextPhase vs AtNextPhaseForPlayer precedent kept these on separate variants. Worth tidying, not worth blocking.

✅ Clean mechanics (approve on split)

Vanille meld triggers (leading condition + optional cost), Niko mass become-copy (turn-agnostic end-step expiry), No Witnesses "controls the most" superlative (with the 3-player tie discriminator), Vincent's Limit Break Tiered P/T distribution, Graceful Takedown multi-source each-deals-power damage, Glen Elendra's Answer counter-all (spells and abilities), and the rebase-reconcile commit — all with byte-verified Oracle text, grep-verified CR annotations, and discriminating, registered tests.

Recommendation: split Moonlit Meditation into a follow-up PR — keep the AppliedReplacementKey migration plumbing on the landing side and carve out only the Moonlit-specific replacement condition, latch, and std_longtail_e.rs tests. With Moonlit removed, this tranche is good to land.

lgray added 2 commits July 10, 2026 20:16
…not per-source

Moonlit Meditation's "The first time you would create one or more tokens each
turn, you may instead create that many tokens that are copies of enchanted
permanent" gated its token-copy replacement on a per-SOURCE latch
(`first_token_replacement_used_this_turn`, keyed by ObjectId). The Oracle "you"
and the official ruling make it a per-PLAYER window: "If you create one or more
tokens, and then Moonlit Meditation comes under your control that same turn, the
replacement effect won't apply to any tokens you create for the rest of the
turn." A token created before Moonlit enters mid-turn consumes the window; the
per-source latch missed this and wrongly fired.

Retarget the `FirstTokenCreationEachTurn` condition to the existing per-player
primitive `players_who_created_token_this_turn` (populated by
`record_token_created` on every token creation — the same set backing
`YouCreatedTokenThisTurn`). This closes the window on both accept (copy tokens)
and decline (original tokens) with no separate bookkeeping, so the per-source
field and its `note_first_token_replacement_applied` writer are now dead and are
removed. Copy re-entry stays suppressed by the applied-set check (CR 614.5), not
the latch. Fix the misapplied CR 614.4 annotation (temporal "can't go back in
time") to CR 614.1a ("instead" = replacement effect) + CR 614.5 (a replacement
doesn't invoke itself repeatedly).

Tests (crates/engine/tests/integration/std_longtail_e.rs): B1 and B5 rewritten as
the two switch-discriminators — a source entering mid-turn after an earlier
creation, and a second source under the same controller — both revert-fail under
the per-source latch. A new accept-path test guards that the window still closes
via record_token_created after removing the note fn. Accept/decline/turn-reset
field assertions retargeted to the per-player set.

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

Copy link
Copy Markdown
Member

Update — the Moonlit Meditation blocker from my prior review (#pullrequestreview-4675737531) is resolved on the new head; holding only for CI + rebase.

✅ Blocker resolved

  • Commit 81312e7c scopes the token-copy window per-player, exactly as recommended: it deletes the per-ObjectId first_token_replacement_used_this_turn set entirely and consumes the shared players_who_created_token_this_turn primitive (populated by record_token_created, reset at turn start) — the existing building block. The annotation is corrected to CR 614.1a for the per-player "first time each turn" window (the misapplied CR 614.4 is gone). This satisfies the card's official ruling that a token created before Moonlit enters mid-turn consumes the window ("the replacement effect won't apply to any tokens you create for the rest of the turn").
  • The tests that had enshrined the per-source model (moonlit_* in std_longtail_e.rs) now assert against players_who_created_token_this_turn, so they pin the per-player semantics instead of masking the fix as a regression.

🟡 Holding on

  • Head is BEHIND main and 6 required checks are still running (Rust shards, lint, coverage, AI/perf gates). The other 7 mechanics were already cleared in the decomposed review on the prior head; the only delta here is the Moonlit fix plus a merge-main commit.

Recommendation

On settled green, update-branch + approve + enqueue as bug. No further changes requested — this is now a CI/rebase gate only.

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

Approving — the Moonlit Meditation blocker from the decomposed review is resolved, and the 7 other mechanics were already cleared.

✅ Resolved

Commit 81312e7c scopes the token-copy window per-player exactly as recommended: the per-ObjectId first_token_replacement_used_this_turn latch is gone, replaced by the shared players_who_created_token_this_turn primitive (set by record_token_created, reset at turn start), the annotation is corrected (CR 614.4 → 614.1a), and the moonlit_* tests that had enshrined the per-source model are fixed. This now matches the card's official ruling that a token created before Moonlit enters mid-turn consumes the once-per-turn window.

✅ Prior decomposed review (unchanged mechanics)

Vanille meld, Niko mass become-copy, No Witnesses superlative, Vincent's Tiered P/T, Graceful Takedown multi-source damage, Glen Elendra counter-all, and the rebase-reconcile commit — all previously verified (Oracle text, grep-verified CR annotations, discriminating registered tests). The only delta since that review is the Moonlit fix plus two merge-main commits; no new mechanics were introduced.

Full rollup settled green (13/13 required checks SUCCESS), merge CLEAN. Enqueuing.

@matthewevans matthewevans added enhancement New feature or request and removed needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) labels Jul 11, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 11, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 11, 2026
@matthewevans
matthewevans merged commit 11d47d1 into phase-rs:main Jul 11, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants