Skip to content

fix(parser): distribute Mutable Pupa's perpetual keyword-mirror trigger per keyword - #6533

Merged
matthewevans merged 19 commits into
phase-rs:mainfrom
jsdevninja:fix/mutable-pupa-perpetual-keyword-mirror
Jul 24, 2026
Merged

fix(parser): distribute Mutable Pupa's perpetual keyword-mirror trigger per keyword#6533
matthewevans merged 19 commits into
phase-rs:mainfrom
jsdevninja:fix/mutable-pupa-perpetual-keyword-mirror

Conversation

@jsdevninja

@jsdevninja jsdevninja commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #6321. Mutable Pupa's second ability was entirely Effect::Unimplemented:

Mutable Pupa {G}
Creature — Insect (1/1), Alchemy: Edge of Eternities (digital-only)
Evolve
Whenever another creature you control enters, this creature perpetually gains flying if that creature has flying. The same is true for first strike, double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance.

(Oracle text verified against Scryfall 508be2d9-c896-4cbf-b574-2896769d7987.)

This is the same "the same is true for K1, K2, …" per-item list-collapse family that #6168 fixed for continuous exiled-object keyword grants (Eater of Virtue, Urborg Scavengers) — but for a triggered, resolve-once perpetual keyword mirror gated on the entering creature, not a continuously re-evaluated IsPresent board search. Evolve (the first ability) was already correctly implemented and is untouched.

Root cause & fix

No AbilityCondition variant checked "the trigger's event-bound object has keyword K" as a plain boolean gate. The fix reuses the existing AbilityCondition::ZoneChangeObjectMatchesFilter (reads state.current_trigger_event, CR 603.2/603.6) and Effect::ApplyPerpetual/PerpetualModification::GrantKeywordsno new runtime-facing enum variants. strip_suffix_conditional gets a new trailing "that creature/permanent has <keyword>" arm, explicitly scoped to ctx.in_trigger (CR 115.1) so it can never intercept the pre-existing, unrelated strip_target_keyword_instead class. A new ReplicateKind::PerpetualKeywordGrant lowering template (parser-internal IR, alongside the existing StaticGrant/CounterPlacement leaves) distributes the keyword list into independent SequentialSibling grants, reusing try_parse_same_is_true_continuation exactly as #6168 did.

The independent-branch chain needed a real runtime fix: resolve_chain_body only re-checked a SequentialSibling's own condition past a preceding sibling's failed gate for two narrow existing cases — so the keyword list would silently collapse at the first false gate. Fixed with a construction-scoped SiblingCondition marker (Dependent default / ReplicatedOrBranch, stamped only by the two per-keyword replication helpers) rather than matching on condition type, since a type-based approach would have regressed Thieving Skydiver's genuinely dependent "if that artifact is an Equipment" continuation (confirmed during review). The identical live bug already shipping in Kathril, Aspect Warper's counter-placement chain is fixed by the same marker — same root cause, same fix, no extra casework.

CR

  • CR 608.2c — instructions resolve in the order written (independent per-item OR-branches).
  • No CR entry for "perpetually" (digital-only Alchemy templating) — annotated per the existing convention.

Tests

  • Parser-shape unit tests: the antecedent clause in isolation, and the full 12-node independent chain (positional assertions — each node gated on its own keyword, not keyword[0]).
  • Non-regression unit tests: Odric, Lunarch Marshal stays GenericEffect (not routed through the new perpetual path); Thieving Skydiver's dependent continuation is never stamped ReplicatedOrBranch.
  • Registered runtime integration tests (crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs) driving the real cast → trigger → resolve pipeline: Mutable Pupa + Affa Protector (single matching keyword), a multi-keyword accumulation case, and a Kathril regression proving the chain now reaches a matching counter and the unconditional tail past a false earlier gate.

Verification

This environment could not run cargo clippy/cargo test — no native (MSVC) linker is available in this sandbox, confirmed directly (even cargo check -p engine fails at the build-script link stage), and Tilt is not running. cargo fmt --all passes. The implementation went through 4 rounds of independent review-and-fix (spawned as fresh, isolated review passes per this repo's engine-implementer pipeline) against the actual current source, which caught and fixed 3 real issues along the way (a missing ResolvedAbility field-propagation site that would have made the fix a no-op, dead code from an earlier design iteration, and a leaked keyword-predicate path that would have regressed several shipping "instead" cards) — but none of that substitutes for an actual compile. CI needs to be the first real compile/test signal for this PR; please treat that as load-bearing, not a formality, given the environment constraint above.

Tier: Frontier

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for perpetual keyword-grant “keyword mirrors,” including correct “the same is true for” continuations with per-keyword gating.
  • Bug Fixes

    • Improved sequential sibling resolution so each sibling’s gate is re-evaluated correctly after earlier failures.
    • Tightened inert/batched resolution so abilities aren’t merged when their sibling-gating behavior differs.
    • Fixed trigger parsing so zone-change keyword riders apply only in the proper trigger context.
    • Improved type-phrase parsing to correctly handle leading “any …” without breaking phrases like “an opponent …”.
  • Tests

    • Added oracle-trigger and integration coverage for Mutable Pupa, Kathril, and related replication/sibling scenarios.

@jsdevninja
jsdevninja requested a review from matthewevans as a code owner July 23, 2026 04:30
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds SiblingCondition metadata to ability structures, propagates it through resolution, parses perpetual keyword-grant replication, resolves replicated siblings independently, updates batching checks, and adds parser and integration regression tests.

Changes

Perpetual keyword mirroring

Layer / File(s) Summary
Sibling-condition contract and propagation
crates/engine/src/types/ability.rs, crates/engine/src/game/ability_*.rs, crates/engine/src/game/effects/vote.rs
Adds SiblingCondition, serializes it with default handling, preserves it during ability construction, and excludes it from read-axis and choice analysis.
Perpetual keyword-grant parsing and replication
crates/engine/src/parser/oracle_effect/*, crates/engine/src/parser/oracle_ir/effect_chain.rs, crates/engine/src/parser/oracle_target.rs
Parses zone-change keyword predicates, identifies perpetual keyword grants, and creates per-keyword replicated siblings with rewritten grants and gates.
Independent sibling gating and batching
crates/engine/src/game/effects/mod.rs, crates/engine/src/game/stack.rs
Evaluates replicated-or-branch siblings independently and restricts compatible inert-trigger batching and equality by sibling condition.
Fixtures and regression coverage
crates/engine/src/game/effects/*, crates/engine/tests/integration/*, crates/engine/src/parser/oracle_trigger_tests.rs
Updates constructed ability fixtures and tests Mutable Pupa, Kathril, parser replication, and non-regression continuation behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant OracleParser
  participant EffectChainAssembler
  participant GameResolver
  participant IntegrationTests
  OracleText->>OracleParser: parse perpetual keyword-grant trigger
  OracleParser->>EffectChainAssembler: select per-keyword replication
  EffectChainAssembler->>GameResolver: attach replicated sibling abilities
  GameResolver->>GameResolver: evaluate independent sibling gates
  IntegrationTests->>GameResolver: resolve Mutable Pupa or Kathril scenario
Loading

Possibly related issues

Possibly related PRs

  • phase-rs/phase#6297: Both changes update triggered-ability batch eligibility and structural equality in stack.rs.

Suggested labels: enhancement

Suggested reviewers: matthewevans, ntindle, lgray

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: per-keyword lowering for Mutable Pupa’s perpetual keyword-mirror trigger.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
crates/engine/src/parser/oracle_effect/mod.rs (1)

21740-21745: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting a shared per-keyword sibling-replication helper.

attach_perpetual_keyword_grants is structurally identical to the existing attach_repeat_process_keywords (clone template → rewrite the effect-specific field → rewrite the condition via rewrite_ability_condition_keyword → stamp sub_link = SequentialSibling / sibling_condition = ReplicatedOrBranch / sub_ability = None → push). The only difference is which Effect field gets the new keyword. This PR itself had to duplicate the sibling_condition marker addition into both functions — exactly the kind of drift risk a shared helper (parameterized by a small closure/mutator for the effect field) would eliminate.

♻️ Sketch of a shared helper
-fn attach_repeat_process_keywords(
-    defs: &mut Vec<AbilityDefinition>,
-    template_index: usize,
-    keywords: &[Keyword],
-) {
-    let template = defs[template_index].clone();
-    for keyword in keywords {
-        let mut new_def = template.clone();
-        if let Effect::PutCounter { counter_type, .. } = &mut *new_def.effect {
-            *counter_type = CounterType::Keyword(keyword.kind());
-        }
-        if let Some(condition) = &mut new_def.condition {
-            rewrite_ability_condition_keyword(condition, keyword);
-        }
-        new_def.sub_link = SubAbilityLink::SequentialSibling;
-        new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch;
-        new_def.sub_ability = None;
-        defs.push(new_def);
-    }
-}
-
-fn attach_perpetual_keyword_grants(
-    defs: &mut Vec<AbilityDefinition>,
-    template_index: usize,
-    keywords: &[Keyword],
-) {
-    let template = defs[template_index].clone();
-    for keyword in keywords {
-        let mut new_def = template.clone();
-        if let Effect::ApplyPerpetual {
-            modification: PerpetualModification::GrantKeywords { keywords: kws },
-            ..
-        } = &mut *new_def.effect
-        {
-            *kws = vec![keyword.clone()];
-        }
-        if let Some(condition) = &mut new_def.condition {
-            rewrite_ability_condition_keyword(condition, keyword);
-        }
-        new_def.sub_link = SubAbilityLink::SequentialSibling;
-        new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch;
-        new_def.sub_ability = None;
-        defs.push(new_def);
-    }
-}
+fn replicate_per_keyword_sibling(
+    defs: &mut Vec<AbilityDefinition>,
+    template_index: usize,
+    keywords: &[Keyword],
+    mut rewrite_effect: impl FnMut(&mut Effect, &Keyword),
+) {
+    let template = defs[template_index].clone();
+    for keyword in keywords {
+        let mut new_def = template.clone();
+        rewrite_effect(&mut new_def.effect, keyword);
+        if let Some(condition) = &mut new_def.condition {
+            rewrite_ability_condition_keyword(condition, keyword);
+        }
+        new_def.sub_link = SubAbilityLink::SequentialSibling;
+        new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch;
+        new_def.sub_ability = None;
+        defs.push(new_def);
+    }
+}

As per coding guidelines, "Build reusable card-class building blocks rather than one-card special cases" and as per path instructions, avoid "any new helper that duplicates an existing building block."

Also applies to: 21763-21814

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/mod.rs` around lines 21740 - 21745,
Extract the duplicated per-keyword sibling construction from
attach_perpetual_keyword_grants and attach_repeat_process_keywords into one
shared helper. Parameterize the helper with the effect-field mutator, while
centralizing template cloning, rewrite_ability_condition_keyword, sibling
metadata initialization (including ReplicatedOrBranch), sub_ability clearing,
and pushing the result; update both callers to use it without changing their
effect-specific behavior.

Sources: Coding guidelines, Path instructions

crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs (1)

208-225: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Counter-count assertions use >= 1 instead of an exact count.

count(CounterType::Keyword(Keyword::Trample.kind())) >= 1 and count(CounterType::Plus1Plus1) >= 1 should be exact (== 1) given the setup (one trample creature card in the graveyard ⇒ exactly one trample counter ⇒ exactly one +1/+1 counter). >= wouldn't catch a regression where a ReplicatedOrBranch node is visited more than once (e.g., a future change to resolve_chain_body's new disjunct double-resolving a sibling), which is precisely the class of bug this test suite is meant to guard against.

♻️ Proposed fix
-    assert!(
-        count(CounterType::Keyword(Keyword::Trample.kind())) >= 1,
-        "trample is in the graveyard ⇒ a trample counter is placed (chain reached past false flying/first-strike/... gates)",
-    );
+    assert_eq!(
+        count(CounterType::Keyword(Keyword::Trample.kind())),
+        1,
+        "trample is in the graveyard ⇒ exactly one trample counter is placed",
+    );
@@
-    assert!(
-        count(CounterType::Plus1Plus1) >= 1,
-        "the unconditional +1/+1 tail must land (chain reaches the end)",
-    );
+    assert_eq!(
+        count(CounterType::Plus1Plus1),
+        1,
+        "exactly one +1/+1 counter (one counter placed on a creature this way)",
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs`
around lines 208 - 225, Update the trample and +1/+1 counter assertions in this
integration test to require exactly one counter instead of at least one. Change
the checks for CounterType::Keyword(Keyword::Trample.kind()) and
CounterType::Plus1Plus1 while preserving the existing zero-count flying
assertion and diagnostic messages.
crates/engine/src/game/effects/mod.rs (1)

8048-8055: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Independent-branch gate doesn't verify sub_link, unlike its sibling disjunct.

sub_is_replicated_or_branch lets a sub resolve regardless of this node's failed condition purely because sibling_condition == ReplicatedOrBranch. The disjunct right below it (sub.sub_link == SequentialSibling && sub.condition.is_none()) explicitly re-checks the link kind, but this new disjunct doesn't. SiblingCondition and SubAbilityLink are independent fields, so nothing here stops a ReplicatedOrBranch marker on a ContinuationStep sub from being treated as an independent OR-branch and resolved past a failed parent gate — the exact semantics this code exists to avoid for dependent continuations. Today's parser always pairs the two, but that invariant isn't enforced at this call site.

🛡️ Proposed defensive guard
-                let sub_is_replicated_or_branch =
-                    sub.sibling_condition == SiblingCondition::ReplicatedOrBranch;
+                let sub_is_replicated_or_branch = sub.sibling_condition
+                    == SiblingCondition::ReplicatedOrBranch
+                    && sub.sub_link == SubAbilityLink::SequentialSibling;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/effects/mod.rs` around lines 8048 - 8055, Update the
`sub_is_replicated_or_branch` disjunct in the surrounding sub-resolution logic
to require `sub.sub_link` to be the independent branch link type as well as
`SiblingCondition::ReplicatedOrBranch`. Preserve the existing
condition-dependent and independent-event-gate checks, and ensure dependent
continuation links cannot bypass a failed parent condition solely because of
their sibling condition marker.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 27072-27097: Update the kind selection for
ReplicateKind::PerpetualKeywordGrant in the current clause-building logic to
inspect the most recent clause whose disposition is not
ClauseDisposition::Continue, rather than using builder.clauses().last().
Preserve the existing ApplyPerpetual with GrantKeywords match and StaticGrant
fallback, using the established reverse-search pattern so absorbed rider clauses
are skipped.

In `@crates/engine/src/parser/oracle_ir/effect_chain.rs`:
- Around line 409-415: Correct the perpetual keyword replication documentation:
in crates/engine/src/parser/oracle_ir/effect_chain.rs lines 409-415, replace the
CR 608.2c citation with verified CR 702.1c; in
crates/engine/src/parser/oracle_effect/assembly.rs lines 641-647, add the
matching CR 702.1c citation and explicitly identify “perpetually” as a
digital-only extension outside the CR.

In `@crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs`:
- Around line 179-200: The Kathril integration test currently provides
insufficient mana and allows duplicate counters to pass. Update the mana setup
in kathril_reaches_matching_counter_and_tail_past_false_earlier_gates to include
the required generic, white, black, and green mana for {2}{W}{B}{G}, then
tighten the Trample and +1/+1 assertions to require exactly one counter each.

---

Nitpick comments:
In `@crates/engine/src/game/effects/mod.rs`:
- Around line 8048-8055: Update the `sub_is_replicated_or_branch` disjunct in
the surrounding sub-resolution logic to require `sub.sub_link` to be the
independent branch link type as well as `SiblingCondition::ReplicatedOrBranch`.
Preserve the existing condition-dependent and independent-event-gate checks, and
ensure dependent continuation links cannot bypass a failed parent condition
solely because of their sibling condition marker.

In `@crates/engine/src/parser/oracle_effect/mod.rs`:
- Around line 21740-21745: Extract the duplicated per-keyword sibling
construction from attach_perpetual_keyword_grants and
attach_repeat_process_keywords into one shared helper. Parameterize the helper
with the effect-field mutator, while centralizing template cloning,
rewrite_ability_condition_keyword, sibling metadata initialization (including
ReplicatedOrBranch), sub_ability clearing, and pushing the result; update both
callers to use it without changing their effect-specific behavior.

In `@crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs`:
- Around line 208-225: Update the trample and +1/+1 counter assertions in this
integration test to require exactly one counter instead of at least one. Change
the checks for CounterType::Keyword(Keyword::Trample.kind()) and
CounterType::Plus1Plus1 while preserving the existing zero-count flying
assertion and diagnostic messages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a0605a22-7ae0-42ae-809a-b9a4996dae5b

📥 Commits

Reviewing files that changed from the base of the PR and between a5574c3 and f9f7084.

⛔ Files ignored due to path filters (1)
  • crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_lowered.snap is excluded by !**/*.snap, !**/snapshots/**
📒 Files selected for processing (23)
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/effects/additional_phase.rs
  • crates/engine/src/game/effects/double.rs
  • crates/engine/src/game/effects/extra_turn.rs
  • crates/engine/src/game/effects/grant_extra_loyalty_activations.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/player_counter.rs
  • crates/engine/src/game/effects/reverse_turn_order.rs
  • crates/engine/src/game/effects/skip_next_step.rs
  • crates/engine/src/game/effects/skip_next_turn.rs
  • crates/engine/src/game/effects/vote.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/parser/oracle_effect/assembly.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_ir/effect_chain.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/mutable_pupa_perpetual_keyword_mirror.rs
  • crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs

Comment thread crates/engine/src/parser/oracle_effect/mod.rs Outdated
Comment thread crates/engine/src/parser/oracle_ir/effect_chain.rs Outdated
@matthewevans matthewevans self-assigned this Jul 23, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 23, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer follow-up on the current head:

  • Accepted the SequentialSibling guard for ReplicatedOrBranch; it keeps the runtime bypass tied to the construction invariant instead of marker presence alone.
  • Kept the two effect-specific replication lowerings separate. They have only two call sites and distinct, exhaustively typed rewrites (PutCounter versus ApplyPerpetual); a closure-based generic helper would add an abstraction without an existing third consumer or shared effect vocabulary.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 10 card(s), 14 signature(s) (baseline: main 438cad092e68)

🟢 Added (4 signatures)

  • 1 card · ➕ ability/ApplyPerpetual · added: ApplyPerpetual (conditional=object entering battlefield is with Flying)
    • Affected (first 3): Mutable Pupa
  • 1 card · ➕ static/CantBeBlockedBy(Typed(TypedFilter { type_filters: [Subtype("Eldrazi"), Subtype(… · added: CantBeBlockedBy(Typed(TypedFilter { type_filters: [Subtype("Eldrazi"), Subtype("Scion")], controller: None, properties: [] })) (affects=self)
    • Affected (first 3): Kor Castigator
  • 1 card · ➕ static/Continuous · added: Continuous (affects=doesn't share name with reference you control creature, mods=power +1, toughness +1)
    • Affected (first 3): Naming Screen
  • 1 card · ➕ ability/PumpAll · added: PumpAll (duration=until end of turn, filter=you control Elder Dragon, kind=activated, p/t=+7/+7)
    • Affected (first 3): Eldest Dragon Highlander

🔴 Removed (4 signatures)

  • 1 card · ➖ static/CantBeBlockedBy(Typed(TypedFilter { type_filters: [Subtype("Eldrazi")], control… · removed: CantBeBlockedBy(Typed(TypedFilter { type_filters: [Subtype("Eldrazi")], controller: None, properties: [] })) (affects=self)
    • Affected (first 3): Kor Castigator
  • 1 card · ➖ ability/Pump · removed: Pump (duration=until end of turn, kind=activated, p/t=+7/+7, target=any target)
    • Affected (first 3): Eldest Dragon Highlander
  • 1 card · ➖ ability/static_structure · removed: static_structure
    • Affected (first 3): Naming Screen
  • 1 card · ➖ ability/~ · removed: ~
    • Affected (first 3): Mutable Pupa

🟡 Modified fields (6 signatures)

  • 1 card · 🔄 ability/BecomeCopy · changed field target: any targetin battlefield creature
    • Affected (first 3): Duplication Device
  • 1 card · 🔄 ability/ChangeZoneAll · changed field target: SandSand Warrior
    • Affected (first 3): Hazezon Tamar
  • 1 card · 🔄 ability/CopyTokenOf · changed field copies: any targetElder Dragon
    • Affected (first 3): Fate Reforged
  • 1 card · 🔄 ability/PutCounter · changed field target: Teddyyou control Teddy Bear
    • Affected (first 3): Push Your Luck
  • 1 card · 🔄 ability/PutCounter · changed field target: any targetyou control creature
    • Affected (first 3): Kathril, Aspect Warper
  • 1 card · 🔄 ability/Transform · changed field target: Humanyou control Human Werewolf
    • Affected (first 3): Tovolar, Dire Overlord

@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: current head c645fe0 is clean for merge-queue processing. The current parse diff is confined to Mutable Pupa; the typed replicated-branch path preserves dependent continuations; and the registered GameRunner regressions cover a late keyword and the Kathril false-prefix path. Required checks remain pending; auto-merge will wait for them.

@matthewevans matthewevans added the quality For high-quality minimal to no-churn PRs label Jul 23, 2026
@matthewevans
matthewevans enabled auto-merge July 23, 2026 05:27

@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 current-head maintainer review.

  • The parser/engine seam and typed sibling-condition threading are correct.
  • The parse-diff is limited to Mutable Pupa's conditional ApplyPerpetual support.
  • The end-to-end regressions cover both late-keyword propagation and the Kathril false-prefix case; the final dereference correction is test-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.

Approved current head 9e91a31641 after the maintainer CI repair.

  • The Kathril regression now declares Kathril as the ETB counter recipient through the existing GameRunner trigger-target selection path; it does not add an incorrect TargetFilter::Any source fallback.
  • The IR expectation now includes sibling_condition: ReplicatedOrBranch on all ten replicated Kathril keyword branches, matching the already-correct lowered snapshot.

cargo fmt --all and git diff --check passed. The worktree's Tilt wait returned exit 3 because Tilt watches the main checkout, so it cannot attest to this worktree; required GitHub checks are running and auto-merge remains enabled.

@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: current head 3c1f1a9b5d contains only the maintainer test-fixture repair. The Kathril regression now funds its actual {2}{W}{B}{G} cost, so the existing cast → ETB → target-selection pipeline reaches the per-keyword-chain assertions it is meant to discriminate. Auto-merge remains enabled; required CI is running.

@matthewevans

matthewevans commented Jul 23, 2026

Copy link
Copy Markdown
Member

Maintainer hold: the current required Rust-tests failure is Kathril's graveyard keyword predicate (expected trample counter 1, got 0). The apparent repair is not a safe one-line test or filter fix: FilterProp::WithKeyword currently reads battlefield-only keywords, while off-zone queries have a kind-only authority; this predicate deliberately uses discriminant semantics, and entry/LKI/event-snapshot filter paths must retain their own snapshot semantics. Please do not patch this at the failing assertion. It needs a narrowly reviewed shared-characteristics boundary plus discriminating live off-zone and snapshot regression coverage before re-enabling auto-merge.

@matthewevans matthewevans removed their assignment Jul 23, 2026
The new drive_resolution handler for WaitingFor::ChooseFromZoneChoice
hard-panicked when a test declared no objects for it, but this variant
predates the Kathril work -- it's the existing CR 608.2d tracked-set
choice mechanism (exiled-cards picks, etc.), and pre-existing tests
using it (Portent of Calamity) never needed to declare anything,
since drive_resolution previously had no handler at all for this
variant and such tests must have relied on a path that didn't reach
here, or on this exact prompt not existing pre-fix.

Match the convention every other default-driven choice in this
function already uses (ScryChoice, SurveilChoice,
ArrangePlanarDeckTopChoice all auto-default rather than requiring
explicit test declaration): try the declared-object pool first so a
test can still pin a specific recipient (as the new Kathril
discriminating test does), then fall back to the front of the legal
set for anything undeclared, instead of panicking.
…drive_resolution

Reverts the previous fix's approach entirely: adding an auto-driving
arm for WaitingFor::ChooseFromZoneChoice to the shared drive_resolution
was the wrong direction. That variant predates this PR (it's the
existing CR 608.2d tracked-set choice mechanism) and Portent of
Calamity's own test (issue_6498) deliberately relies on .resolve()
STOPPING at the first such prompt so it can drive each per-type exile
pick manually, one at a time, with its own selection logic -- exactly
like the pre-existing Wick test does. Auto-driving it out from under
that test silently consumed all 4 of its prompts before the test's own
loop ever ran, dropping its exile count to 0.

Removed the arm entirely, restoring the original `_ => break` handling
for this variant. Updated the new Kathril discriminating test to match
the same manual-driving convention Portent's and Wick's tests already
use: call .resolve() (which now correctly stops at the first prompt),
then loop over WaitingFor::ChooseFromZoneChoice states directly,
answering each with the declared recipient in written order.
…ing it

Reconciling the coverage-parse-diff blast radius from the "any"
quantifier widening surfaced a real, pre-existing gap: the CR 205.3a
"[Subtype] [CoreType]" promotion (e.g. "Wizard creatures") only ever
consumed a SECOND word when it was a concrete core type. A second
consecutive SUBTYPE word (e.g. "Elder Dragon", "Elf Warrior", "Human
Wizard" -- two independently-registered subtypes stacked on one
permanent, not a compound word) was silently dropped, since the
`type_filters` builder discards it (the caller doesn't require an
empty remainder).

Fate Reforged chapter II ("a copy of any Elder Dragon from the Legends
expansion") is the concrete case the parse-diff surfaced: before the
"any" fix this collapsed all the way to TargetFilter::Any (matching
literally anything); after it, "any " correctly reaches the subtype
parser, which then only captured Subtype("Elder") and dropped
"Dragon" -- an over-broad filter matching any Elder-subtype creature,
not specifically Elder Dragons. Chained the second subtype word into
the same slot the first arm already threads through, matching the
Legends-era Elder Dragon type line (Nicol Bolas, Palladia-Mors, etc.)
correctly. This is a general fix, not a Fate-Reforged special case --
it benefits any card using a two-subtype phrase, most of which never
appeared in this PR's diff because they were broken the same way
before the "any" fix too, just silently.

Card-level reconciliation for the remaining coverage-parse-diff
entries (issue phase-rs#6321 / PR phase-rs#6533 review):
- Naming Screen: "doesn't share a name with any other creature you
  control" -- parse_shared_quality_reference explicitly rejects a
  TargetFilter::Any reference population, so before the "any"+"other"
  composition fix this whole relative clause failed to parse and the
  static ability fell through to an unstructured fallback. Proven via
  parse_shared_quality_clause_naming_screen_reference using the card's
  verbatim clause.
- Duplication Device: "becomes a copy of any creature on the
  battlefield" -- Effect::BecomeCopy's target is parsed via the same
  shared parse_target this whole fix touches (oracle_effect/subject.rs);
  "any creature on the battlefield" now correctly reaches the
  pre-existing, unmodified zone-suffix machinery instead of collapsing
  to Any. Proven via parse_type_phrase_any_creature_on_the_battlefield.
- Kathril, Mutable Pupa: already covered by this PR's existing
  integration tests.
@jsdevninja
jsdevninja force-pushed the fix/mutable-pupa-perpetual-keyword-mirror branch from 0b74059 to 6b2d818 Compare July 24, 2026 17:11

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

Current-head blocker — do not enqueue

[HIGH] The new consecutive-subtype branch misparses card names as a conjunction of subtype filters and fails required CI.

Evidence: current head f57544fbde85e342d36289cf9bc06112c31b6062; the branch at crates/engine/src/parser/oracle_target.rs:2537 consumes every second TypeFilter::Subtype as an AND-combined subtype. CI run 30113382940, Rust tests shard 1/2, now fails parser::oracle::tests::urzas_lands_share_delta_shape: "Urzas Mine" becomes Subtype("Urzas") + Subtype("Mine"), yielding a QuantityCheck instead of the expected two ControllerControlsMatching conditions. The same change makes legacy_misparses_are_now_honest_gaps parse the three Urza-land names as a nonempty QuantityComparison rather than leave them unsupported.

Why it matters: the generic rule cannot tell a card name from two independent creature subtypes. Reverting only the failing assertions would make previously honest parser gaps silently incorrect; retaining the branch breaks existing behavior. The branch also still cites CR 205.3a for this interpretation at lines 2537 and 15371, but that rule says only that a card may have one or more subtypes. The relevant support for separate creature subtypes is CR 205.3b + CR 205.3m, which must be verified and cited only if the redesigned grammar actually implements that distinction.

Required evidence is also unavailable: the current-head parse-diff artifact (parse-diff, created 2026-07-24T17:33:19Z in the same run) remains Baseline pending for 192897ca50a2d8daf3306f4d2a446b02bee64aa2.

Requested resolution: rework or remove this generic subtype-chain expansion at the type-phrase grammar seam. It needs a reviewed distinction for recognized creature-subtype sequences versus card names/other subtype categories, discriminating positive and negative parser coverage (including the existing Urza cases), corrected CR citations, and a current card-level parse-diff before re-review. A two-line citation change is insufficient.

@matthewevans

Copy link
Copy Markdown
Member

Correction to the quoted CI evidence above: the exact card names are Urza's Mine, Urza's Power-Plant, and Urza's Tower; the apostrophes were stripped while formatting the review body. The CI output and the code location cited in that review retain the exact spellings. The blocker and requested resolution are unchanged.

@matthewevans matthewevans removed their assignment Jul 24, 2026
…aining

The two-consecutive-subtype-word chain added for Fate Reforged's
"any Elder Dragon" regressed the dedicated Urza-lands condition parser:
"Urza's" (LAND_SUBTYPES, card_type.rs) is the one possessive-suffixed
entry in the whole subtype vocabulary -- a name fragment meant to
attach to a following noun ("Urza's Mine"/"Tower"/"Power-Plant"), not
an independently AND-combinable subtype like "Elder"/"Elf"/"Human".

Chaining it fully consumed "an urza's mine" into one
Typed{Subtype("Urza's"), Subtype("Mine")} filter with an empty
remainder, which changed which downstream condition-builder claimed
the clause and broke urzas_lands_share_delta_shape (expected
ControllerControlsMatching{filter} with get_subtype() == "Mine", got
a QuantityCheck over the two-subtype AND filter instead) and
legacy_misparses_are_now_honest_gaps (expected None -- an
intentionally undocumented gap -- got a confidently wrong
QuantityComparison).

Guarded the chain on the first subtype word not ending in "'s"
(verified "Urza's" is the only such entry across LAND_SUBTYPES,
ARTIFACT_SUBTYPES, ENCHANTMENT_SUBTYPES, SPELL_SUBTYPES,
BATTLE_SUBTYPES, PLANESWALKER_SUBTYPES, and the MTGJSON-derived
creature vocabulary), restoring "urza's mine" to its original
Subtype("Urza's") + unconsumed "mine" remainder so the specialized
handler still sees it, while keeping the chain for genuine two-word
subtype pairs.
@jsdevninja

Copy link
Copy Markdown
Contributor Author

Fixed on 7c48b740d. My two-consecutive-subtype-word chain (added to fix Fate Reforged's "any Elder Dragon") was too broad — it also caught "Urza's Mine"/"Tower"/"Power-Plant". "Urza's" turns out to be the one possessive-suffixed entry in the entire subtype vocabulary (LAND_SUBTYPES in card_type.rs) — a name fragment meant to attach to a following noun, not an independently AND-combinable subtype like "Elder"/"Elf"/"Human". Chaining it fully consumed "an urza's mine" into one filter, which changed which downstream condition-builder claimed the clause and broke the dedicated Urza-lands ControllerControlsMatching parser (urzas_lands_share_delta_shape, legacy_misparses_are_now_honest_gaps).

Guarded the chain on the first subtype word not ending in 's — verified it's the only such entry across every subtype list (creature JSON + all six noncreature Rust lists) — which restores "urza's mine" to its exact original parse (Subtype("Urza's") alone, "mine" left unconsumed for the specialized handler) while keeping the chain for genuine two-word pairs. Added a targeted regression test for it.

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

Current-head changes requested — do not enqueue

[HIGH] The Urza guard is a special case at the wrong parser boundary, not the required class-level distinction.

Evidence: head 7c48b74 replaces the prior generic subtype chain with s.ends_with("'s") at crates/engine/src/parser/oracle_target.rs:2537. That suppresses only one observed token, while the branch still asks the all-category parse_type_filter_word result to decide whether two consecutive words are independent creature subtypes. The existing authority in crates/engine/src/parser/oracle_util.rs:1271-1292, parse_creature_subtype, is explicitly restricted to the CR 205.3m creature-subtype vocabulary; it is the appropriate building block for the Elder Dragon class and naturally excludes noncreature subtype/card-name fragments such as Urza’s.

Why it matters: the new guard makes this particular regression green without establishing the grammar invariant for the class. Another noncreature subtype sequence or name fragment will still be consumed as an AND filter, and the parser continues to conflate subtype categories. Rework the consecutive-word branch through the creature-only authority (or an equivalent typed category boundary), with positive examples and negative noncreature/name-fragment coverage. Do not retain a data-specific possessive exception.

[MED] The two new chain annotations still cite CR 205.3a at oracle_target.rs:2555 and :15391. CR 205.3a says only that a card may have one or more subtypes; it does not describe how creature subtype words are separated. Verify and use CR 205.3b + CR 205.3m if the corrected grammar actually implements that behavior.

[HIGH] Required external evidence is not yet available. Current CI run 30121391095 still has Rust lint, both Rust-test shards, and Card data in progress; no parse-diff artifact exists for this head yet. The previous artifact was baseline-pending and cannot evidence the new head. Therefore this PR is not approval- or queue-eligible regardless of the dashboard recommendation.

Requested resolution: use the existing creature-only subtype parser at the type-phrase seam, correct the CR annotations, add discriminating class/negative tests, and wait for green current-head CI plus the current parse artifact before requesting re-review.

@matthewevans

Copy link
Copy Markdown
Member

Parse-artifact update for current head 7c48b74:

The current run 30121391095 has now published parse-diff artifact 8607708221 against main 438cad0. It reports 10 cards and 14 signatures, not a narrow Mutable Pupa/Kathril-only delta:

  • Added: Mutable Pupa; Kor Castigator (Eldrazi Scion); Naming Screen; Eldest Dragon Highlander (Elder Dragon).
  • Modified: Duplication Device; Hazezon Tamar (Sand Warrior); Fate Reforged (Elder Dragon); Push Your Luck (Teddy Bear); Kathril, Aspect Warper; Tovolar, Dire Overlord (Human Werewolf).

The PR body and focused tests do not reconcile or give card-level/discriminating coverage for Kor Castigator, Eldest Dragon Highlander, Hazezon Tamar, Push Your Luck, or Tovolar. These are exactly the broader two-subtype grammar class that the new possessive exception does not model. This is independent evidence that the parser change has unreviewed blast radius; do not approve or enqueue until every signature is explained and the grammar is corrected at the creature-only subtype seam.

Current Rust lint and both Rust test shards remain pending. The earlier note that no artifact existed was accurate when filed; this published artifact strengthens the hold.

@matthewevans matthewevans removed their assignment Jul 24, 2026
Redesigns the previous "exclude Urza's specifically" patch into a
principled, CR-grounded rule instead of an ad-hoc exception.

CR 205.3b: subtypes of every card type except creature (and plane) are
always single words on a printed type line -- each dash-separated word
is its own subtype. Creature subtypes are the one category the rules
let run one OR two words (CR 205.3m: the sole two-word creature type
is "Time Lord"; every other listed type, including "Elder"/"Dragon"/
"Elf"/"Warrior"/"Human"/"Wizard", is one word). So when Oracle text
names two consecutive creature-subtype words, that is genuinely
ambiguous -- one two-word type, or two one-word types stacked -- in a
way that never arises for other categories, where CR 205.3b already
guarantees each word is separate.

This generic chain exists to resolve exactly that creature-only
ambiguity, so it is now scoped to fire ONLY when neither matched word
is a registered noncreature subtype (fixed_noncreature_subtypes --
land/artifact/enchantment/spell/battle/planeswalker), replacing the
narrower "does the first word end in 's" check. "Urza's" is a real
land type (CR 205.3i) -- land subtypes genuinely CAN co-occur on one
permanent (Urza's Mine has both the "Urza's" and "Mine" land
subtypes) -- but the dedicated Urza-lands ControllerControlsMatching
condition parser already owns that Oracle-text pattern and
deliberately extracts only the discriminating second word, so this
generic rule must stay out of its way, and out of every other
noncreature category's way too, not just this one land cycle.

Also corrects the CR citation on the chaining rule itself from 205.3a
(which only establishes that a card may have subtypes at all) to
205.3b + 205.3m (which establish the actual one-vs-two-word creature
distinction the rule implements) -- 205.3a stays correctly cited on
the unrelated pre-existing "[Subtype] [CoreType]" promotion arm beside
it, which is a different pattern.
@jsdevninja

Copy link
Copy Markdown
Contributor Author

Addressed on 50d1de7e3.

One clarification on the evidence first: f57544fbd is the commit before 7c48b740d, which had already landed a fix for the exact urzas_lands_share_delta_shape / legacy_misparses_are_now_honest_gaps failures cited (CI run 30113382940 appears to have been queued against the pre-fix commit). But the deeper point stands on its own merits regardless of which commit CI saw, so I didn't just point at the stale SHA — I did the rework.

CR citation, verified against docs/MagicCompRules.txt: you're right that 205.3a is the wrong cite for this — it only establishes that a card can have subtypes at all. The rule that actually explains the one-vs-two-word ambiguity this code resolves is CR 205.3b + CR 205.3m: subtypes of every card type except creature (and plane) are always single words on a printed type line; creature subtypes are the one category the rules let run one or two words (the sole two-word creature type is "Time Lord" — every other type in the 205.3m list is one word). So two consecutive creature-subtype words in Oracle text are genuinely ambiguous — one two-word type, or two one-word types stacked — in a way that never arises for other categories, where 205.3b already guarantees each word is separate. Fixed the citation (kept 205.3a correctly cited on the different, pre-existing [Subtype][CoreType] promotion arm beside it).

"The generic rule cannot tell a card name from two independent creature subtypes" — this is the real finding, and the "Urza's" patch didn't actually address it (it excluded one instance, not the class). Reworked: the chain now fires only when neither matched word is a registered noncreature subtype (fixed_noncreature_subtypes — land/artifact/enchantment/spell/battle/planeswalker), replacing the ad-hoc "ends in 's" check. This is scoped by the actual CR distinction rather than the one symptom I'd found. Worth noting for the record: land subtypes genuinely can co-occur on one permanent — CR 205.3i confirms "Urza's" is a real land type, and Urza's Mine has both the "Urza's" and "Mine" land subtypes — so the fix isn't "noncreature subtypes never combine" (that would be CR-inaccurate), it's that this generic phrase-chaining rule is scoped to the creature-only word-boundary ambiguity and must stay out of every other category's way, deferring to whatever specialized parser already owns that category (here, the dedicated Urza-lands ControllerControlsMatching builder, which deliberately extracts only the discriminating second word since "Urza's" is common to all three cycle members).

Discriminating coverage: parse_type_phrase_two_word_subtype_chain (positive — Elder Dragon/Elf Warrior/Human Wizard, all creature-only) and parse_type_phrase_urzas_possessive_prefix_does_not_chain (negative — confirms "mine" stays unconsumed, not chained), plus the pre-existing urzas_lands_share_delta_shape and legacy_misparses_are_now_honest_gaps as end-to-end regression coverage for the specific case that was broken.

A fresh parse-diff will generate against this commit on the next CI run.

@jsdevninja
jsdevninja requested a review from matthewevans July 24, 2026 20:03
@matthewevans matthewevans self-assigned this Jul 24, 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.

Maintainer sign-off on 50d1de7: current CI is green; the current parse artifact is reconciled; the subtype-chain boundary now excludes fixed noncreature subtype categories and retains the dedicated Urza-land path.

@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@matthewevans matthewevans removed their assignment Jul 24, 2026
Merged via the queue into phase-rs:main with commit 3dbe341 Jul 24, 2026
14 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.

Mutable Pupa: 'the same is true for' perpetual keyword mirror is entirely unparsed

2 participants